diff --git a/docs/courier-test-vectors.json b/docs/courier-test-vectors.json new file mode 100644 index 00000000..82d24bce --- /dev/null +++ b/docs/courier-test-vectors.json @@ -0,0 +1,137 @@ +{ + "_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" + }, + + "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: 104 + 16 = 120 <= 256, so target 256,", + "shortfall 152 = 0x98.", + "", + "Signing the 104 unpadded bytes produces a signature that never verifies,", + "and nothing in any log points at the cause." + ], + "packet": { + "_comment": "A real courierEnvelope deposit frame: type 0x04 carrying the encoded envelope from envelopeTLV.encoded above as its payload.", + "version": 1, + "type": "0x04", + "ttlOnWire": 7, + "payloadIs": "envelopeTLV.encoded (74 bytes)" + }, + "unsignedUnpaddedLength": 104, + "unsignedUnpadded": "010407000001977420dc0001004a112233445566778899aabbccddeeff00010010000102030405060708090a0b0c0d0e0f020008000001a3185c500003001e636f75726965722d766563746f722d636970686572746578742d303030310400010405000411223344", + "signingPreimageLength": 256, + "signingPreimagePadByte": "0x98", + "signingPreimagePadCount": 152, + "signingPreimage": "010400000001977420dc0001004a112233445566778899aabbccddeeff00010010000102030405060708090a0b0c0d0e0f020008000001a3185c500003001e636f75726965722d766563746f722d636970686572746578742d3030303104000104050004112233449898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898989898", + "signedWireLength": 168, + "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 168-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..86716ee4 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierVectorTests.swift @@ -0,0 +1,336 @@ +// +// 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, published as +/// `docs/courier-test-vectors.json` so a second implementation can check itself +/// without running this app. +/// +/// Every value asserted below is **read from that file**, not duplicated here. +/// A format change that updates only the Swift side leaves the published JSON +/// stale, and a stale JSON fails these tests — which is the only thing that +/// makes the document trustworthy to someone who cannot run it. +/// +/// 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: Loading the published vectors + + /// The published file, located relative to this source file. It lives at + /// repo-root `docs/`, outside this package, so it cannot be a SwiftPM + /// resource the way `NoiseTestVectors.json` is — the repo already resolves + /// a repo-root path this way in `bitchatTests/LocalizationCoverageTests`. + static let vectorFileURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // → BitFoundationTests + .deletingLastPathComponent() // → Tests + .deletingLastPathComponent() // → BitFoundation + .deletingLastPathComponent() // → localPackages + .deletingLastPathComponent() // → repo root + .appendingPathComponent("docs/courier-test-vectors.json") + + struct MissingVectorFile: Error, CustomStringConvertible { + let path: String + var description: String { + "docs/courier-test-vectors.json not found at \(path). These tests assert " + + "the published vectors; they must not silently pass without them." + } + } + + static func loadVectors() throws -> Vectors { + guard FileManager.default.fileExists(atPath: vectorFileURL.path) else { + throw MissingVectorFile(path: vectorFileURL.path) + } + return try JSONDecoder().decode(Vectors.self, + from: try Data(contentsOf: vectorFileURL)) + } + + /// Decoded shape of the published file. `_comment` keys are prose for human + /// readers and are deliberately not decoded; every other key is, so a field + /// cannot be renamed or dropped without failing here. + struct Vectors: Decodable { + struct Inputs: Decodable { + let recipientTag: String + let noiseStaticKey: String + let ciphertextUTF8: String + let ciphertext: String + let expiryMillis: UInt64 + let epochDay: UInt32 + let senderID: String + let recipientID: String + let timestampMillis: UInt64 + let signingSeed: String + } + struct EnvelopeTLV: Decodable { + let copies: UInt8 + let prekeyID: UInt32 + let encoded: String + let encodedLength: Int + } + struct CopiesClamping: Decodable { + struct Case: Decodable { + let requested: UInt8 + let stored: UInt8 + } + let maxCopies: UInt8 + let cases: [Case] + } + struct RecipientTagDerivation: Decodable { + let label: String + let epochDay: UInt32 + let expected: String + } + struct PacketSigning: Decodable { + struct Packet: Decodable { + let version: UInt8 + let type: String + let ttlOnWire: UInt8 + /// Declared so it cannot be renamed or dropped unnoticed; the + /// byte count it names is asserted against the real payload. + let payloadIs: String + } + struct Flags: Decodable { + let unsignedUnpadded: String + let signingPreimage: String + let signedWirePacket: String + } + let packet: Packet + let unsignedUnpaddedLength: Int + let unsignedUnpadded: String + let signingPreimageLength: Int + let signingPreimagePadByte: String + let signingPreimagePadCount: Int + let signingPreimage: String + let signedWireLength: Int + let flags: Flags + } + struct Signature: Decodable { + let publicKey: String + let signatureLength: Int + let deterministic: Bool + /// Declared for the same reason as `payloadIs`: an undeclared key + /// can vanish from the published file and every test stays green. + let verify: String + } + + let inputs: Inputs + let envelopeTLV: EnvelopeTLV + let copiesClamping: CopiesClamping + let recipientTagDerivation: RecipientTagDerivation + let packetSigning: PacketSigning + let signature: Signature + } + + /// Fails loudly if the published file is missing, renamed, or has lost a + /// key — otherwise a rename would skip every assertion below while the + /// suite still reported green. + @Test func publishedVectorFileLoads() throws { + let v = try Self.loadVectors() + #expect(v.inputs.ciphertextUTF8 == "courier-vector-ciphertext-0001") + #expect(v.copiesClamping.cases.isEmpty == false) + } + + // MARK: Decoded inputs + + private static func hex(_ string: String) throws -> Data { + try #require(Data(hexString: string), "not valid hex: \(string)") + } + + /// `"0x03"` → `3`. + private static func flagByte(_ string: String) throws -> UInt8 { + let digits = string.hasPrefix("0x") ? String(string.dropFirst(2)) : string + return try #require(UInt8(digits, radix: 16), "not a hex byte: \(string)") + } + + private static func envelope(from v: Vectors) throws -> CourierEnvelope { + CourierEnvelope(recipientTag: try hex(v.inputs.recipientTag), + expiry: v.inputs.expiryMillis, + ciphertext: try hex(v.inputs.ciphertext), + copies: v.envelopeTLV.copies, + prekeyID: v.envelopeTLV.prekeyID) + } + + // MARK: Envelope + + /// `expiry` is milliseconds since epoch, big-endian, in an 8-byte TLV. + @Test func envelopeTLVEncoding() throws { + let v = try Self.loadVectors() + + // The published ciphertext hex and its UTF-8 source must agree, or the + // document contradicts itself. + #expect(try Self.hex(v.inputs.ciphertext) == Data(v.inputs.ciphertextUTF8.utf8)) + + let encoded = try #require(try Self.envelope(from: v).encode()) + #expect(encoded.hexEncodedString() == v.envelopeTLV.encoded) + #expect(encoded.count == v.envelopeTLV.encodedLength) + + // The prose in `packet.payloadIs` states this byte count. Prose that + // states a number is a claim like any other, and this is the one place + // it can be checked rather than trusted. + #expect(v.packetSigning.packet.payloadIs.contains("\(encoded.count) bytes"), + "packet.payloadIs disagrees with the real payload length") + + // 0x02 carries 000001a3185c5000 == 1_800_000_000_000 ms, not seconds. + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.expiry == v.inputs.expiryMillis) + #expect(decoded.copies == v.envelopeTLV.copies) + #expect(decoded.prekeyID == v.envelopeTLV.prekeyID) + } + + /// `copies` is clamped into 1...maxCopies, never rejected. An implementation + /// that rejects out-of-range values drops envelopes this one accepts. + @Test func copiesAreClampedNotRejected() throws { + let v = try Self.loadVectors() + #expect(CourierEnvelope.maxCopies == v.copiesClamping.maxCopies) + + for testCase in v.copiesClamping.cases { + let stored = CourierEnvelope(recipientTag: try Self.hex(v.inputs.recipientTag), + expiry: v.inputs.expiryMillis, + ciphertext: try Self.hex(v.inputs.ciphertext), + copies: testCase.requested).copies + #expect(stored == testCase.stored, + "copies(\(testCase.requested)) expected \(testCase.stored), got \(stored)") + } + } + + /// HMAC-SHA256(noiseStaticKey, "bitchat-courier-tag-v1" || BE32(epochDay)), + /// truncated to 16 bytes. + @Test func recipientTagDerivation() throws { + let v = try Self.loadVectors() + let noiseStaticKey = try Self.hex(v.inputs.noiseStaticKey) + #expect(v.recipientTagDerivation.epochDay == v.inputs.epochDay) + + let tag = CourierEnvelope.recipientTag(noiseStaticKey: noiseStaticKey, + epochDay: v.recipientTagDerivation.epochDay) + #expect(tag.hexEncodedString() == v.recipientTagDerivation.expected) + #expect(tag.count == CourierEnvelope.tagLength) + + // Recompute from the published `label` so that field is load-bearing + // too: the implementation's context string is private, so without this + // the document could name the wrong label and nothing would notice. + var message = Data(v.recipientTagDerivation.label.utf8) + withUnsafeBytes(of: v.recipientTagDerivation.epochDay.bigEndian) { message.append(contentsOf: $0) } + let recomputed = Data(HMAC.authenticationCode( + for: message, using: SymmetricKey(data: noiseStaticKey) + ).prefix(CourierEnvelope.tagLength)) + #expect(recomputed == tag) + } + + // MARK: Packet canonicalization — the trap worth a vector + + /// A real `courierEnvelope` frame: type 0x04 carrying an encoded envelope, + /// which is what a deposit actually puts on the wire. + static func envelopePacket(from v: Vectors) throws -> BitchatPacket { + BitchatPacket(type: try flagByte(v.packetSigning.packet.type), + senderID: try hex(v.inputs.senderID), + recipientID: try hex(v.inputs.recipientID), + timestamp: v.inputs.timestampMillis, + payload: try #require(try envelope(from: v).encode()), + signature: nil, + ttl: v.packetSigning.packet.ttlOnWire) + } + + /// 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. + /// Both sequences are compared in full — a change to timestamp encoding, + /// payload length, or field layout has to fail here, otherwise the JSON + /// fixture could go stale while this stayed green. + @Test func signingPreimageIsTTLZeroedAndPadded() throws { + let v = try Self.loadVectors() + let signing = v.packetSigning + let packet = try Self.envelopePacket(from: v) + + let unpadded = try #require(BinaryProtocol.encode(packet, padding: false)) + // #require, not #expect: every assertion below subscripts these bytes, + // and `Data` subscripting past the end traps. A short frame has to fail + // the test, not crash the process and take the rest of the run with it. + try #require(unpadded.count == signing.unsignedUnpaddedLength) + #expect(unpadded.hexEncodedString() == signing.unsignedUnpadded) + + let preimage = try #require(packet.toBinaryDataForSigning()) + try #require(preimage.count == signing.signingPreimageLength) + #expect(preimage.hexEncodedString() == signing.signingPreimage) + + // The pad byte equals the shortfall to the block boundary, and every + // pad byte equals it. + let padByte = try Self.flagByte(signing.signingPreimagePadByte) + #expect(Int(padByte) == signing.signingPreimagePadCount) + #expect(signing.unsignedUnpaddedLength + signing.signingPreimagePadCount + == signing.signingPreimageLength) + #expect(preimage.dropFirst(signing.unsignedUnpaddedLength).count + == signing.signingPreimagePadCount) + #expect(preimage.dropFirst(signing.unsignedUnpaddedLength).allSatisfy { $0 == padByte }) + + #expect(preimage[BinaryProtocol.Offsets.flags] + == (try Self.flagByte(signing.flags.signingPreimage))) + #expect(unpadded[BinaryProtocol.Offsets.flags] + == (try Self.flagByte(signing.flags.unsignedUnpadded))) + #expect(unpadded[2] == signing.packet.ttlOnWire && preimage[2] == 0) + #expect(unpadded[0] == signing.packet.version) + } + + /// Ed25519 — CryptoKit's `Curve25519.Signing`, not X25519 key agreement. + @Test func signatureOverPreimageVerifies() throws { + let v = try Self.loadVectors() + let packet = try Self.envelopePacket(from: v) + let preimage = try #require(packet.toBinaryDataForSigning()) + + // Pin what is being signed. Without this the test is self-consistent by + // construction — it would sign whatever it was handed, verify it, and + // pass over a wrong canonicalization. Proven by mutating an input and + // watching this line, not the verification below, be the one that fails. + #expect(preimage.hexEncodedString() == v.packetSigning.signingPreimage) + + let key = try Curve25519.Signing.PrivateKey( + rawRepresentation: try Self.hex(v.inputs.signingSeed) + ) + #expect(key.publicKey.rawRepresentation.hexEncodedString() == v.signature.publicKey) + + // 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(v.signature.deterministic == false) + // The published instruction must keep naming the field it points at, + // so renaming `signingPreimage` cannot leave it dangling. + #expect(v.signature.verify.contains("signingPreimage")) + #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) + #expect(Data(a).count == v.signature.signatureLength) + + // On the wire the packet carries the 64-byte signature on top of the + // unsigned frame, and the flags byte now also carries hasSignature. + var signed = packet + signed.signature = Data(a) + let wire = try #require(BinaryProtocol.encode(signed, padding: false)) + try #require(wire.count == v.packetSigning.signedWireLength) + #expect(wire.count == v.packetSigning.unsignedUnpaddedLength + v.signature.signatureLength) + #expect(wire[BinaryProtocol.Offsets.flags] + == (try Self.flagByte(v.packetSigning.flags.signedWirePacket))) + } +}