import Foundation import Testing import CryptoKit @testable import BitFoundation /// Executable test vectors for peer ID rotation. /// /// These are the numbers the Android implementation must reproduce. Two rules /// for keeping them useful: /// /// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.** /// Deriving the expected values by reading the other platform's /// implementation proves only that both share a bug. /// 2. **If a derivation changes, the hex here changes too, deliberately.** A /// vector that gets "fixed" to match new behavior has stopped being a vector. /// /// The three `VECTOR:` values below were cross-checked against an independent /// HKDF/HMAC implementation written from the specification alone (Python /// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte. /// So the spec text is sufficient to reproduce them without reading this code — /// which is the property Android needs. struct PeerIDRotationTests { // A fixed, obviously-fake private key so the vectors are stable. private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20 private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf private func hex(_ data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } // MARK: - Epochs @Test func epochIsWallClockDivision() { #expect(PeerIDRotation.rotationPeriod == 3600) #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0) #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0) #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1) // 2026-07-26T00:00:00Z #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555) } @Test func candidateEpochsCoverTheBoundaryBothWays() { // Two devices seconds apart across a boundary must still recognise each // other, so the window spans the neighbouring epochs. let date = Date(timeIntervalSince1970: 3600 * 100) #expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101]) } @Test func candidateEpochsDoNotUnderflowAtTheOrigin() { // UInt32 underflow here would produce 4294967295 and break matching. #expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1]) } // MARK: - Rotating peer ID @Test func rotationSecretIsStableForAKey() { let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) #expect(first == second) #expect(first.count == 32) // VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32) #expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09") } @Test func peerIDIsEightBytesAndEpochDependent() { let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100) let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101) #expect(a.count == PeerIDRotation.idLength) #expect(b.count == PeerIDRotation.idLength) // VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8] #expect(hex(a) == "f7c08c528506a374") // The whole point: consecutive epochs are unrelated to an observer. #expect(a != b) // Deterministic within an epoch, so a restart keeps the same ID. #expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)) } @Test func peerIDDiffersBetweenDevices() { let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB) #expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100) != PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100)) } @Test func currentPeerIDMatchesTheExplicitEpochForm() { let date = Date(timeIntervalSince1970: 3600 * 100 + 17) let viaConvenience = PeerIDRotation.currentPeerID( noiseStaticPrivateKey: staticPrivateA, at: date ) let viaParts = PeerIDRotation.peerID( rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA), epoch: 100 ) #expect(viaConvenience == viaParts) } // MARK: - Recognition tags /// The property that makes handshake-free recognition possible: both sides /// reach the same tag from opposite halves of the key pair. @Test func bothSidesDeriveTheSameRecognitionTag() throws { let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA) let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB) let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey) let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey) let rawA = sharedFromA.withUnsafeBytes { Data($0) } let rawB = sharedFromB.withUnsafeBytes { Data($0) } #expect(rawA == rawB) let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA) let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB) #expect(keyA == keyB) let tagA = PeerIDRotation.recognitionTag(recognitionKey: keyA, epoch: 100) let tagB = PeerIDRotation.recognitionTag(recognitionKey: keyB, epoch: 100) #expect(tagA == tagB) #expect(tagA.count == PeerIDRotation.idLength) } @Test func recognitionTagRotatesWithTheEpoch() { let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) let now = PeerIDRotation.recognitionTag(recognitionKey: key, epoch: 100) let next = PeerIDRotation.recognitionTag(recognitionKey: key, epoch: 101) #expect(now != next) // VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"), uint32be(100))[0..8] #expect(hex(now) == "36400502fa59f4a9") } @Test func aThirdPartyCannotDeriveAPairsTag() { // An observer holding a *different* shared secret gets a different tag, // which is what stops it from tracking the pair. let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32)) let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32)) #expect(PeerIDRotation.recognitionTag(recognitionKey: pair, epoch: 7) != PeerIDRotation.recognitionTag(recognitionKey: other, epoch: 7)) } // MARK: - Tag block @Test func tagBlockIsAlwaysFullWidth() { let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength for count in 0...PeerIDRotation.tagSlots { let tags = (0..