diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index ddcc4abc..fadddade 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -15,7 +15,15 @@ enum BLEOutboundPacketPolicy { // voiceFrame is deliberately unpadded: padding to the 512 block would // push every ~490-byte signed voice packet over the MTU into the // fragment path. - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: + // + // announceV2 is unpadded too, but for a different reason and it is worth + // revisiting: it is ~75 bytes, so the smallest bucket would triple the + // airtime of the most frequently sent packet in the protocol. Its length + // is already near-constant by construction (the tag block is fixed + // width); the residual variation is the capability width and whether a + // bridge geohash is present. Making those fixed-width would be cheaper + // than padding. See docs/PEER-ID-ROTATION.md. + case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 919c3820..01287b6d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -7025,7 +7025,16 @@ extension BLEService { switch context.messageType { case .announce: handleAnnounce(packet, from: senderID) - + + case .announceV2: + // Parsed and ignored on purpose. The wire format and derivations are + // implemented and tested (see PeerIDRotation, AnnounceV2Packet), but + // consuming presence from it needs the replacement identity binding + // and the peer-list policy for unverified presence, both of which are + // still open questions in docs/PEER-ID-ROTATION.md. Accepting it now + // would add unauthenticated entries to the peer list. + break + case .message: handleMessage(packet, from: senderID) diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index c2c96a1c..4dbbb53a 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -57,6 +57,11 @@ struct SyncTypeFlags: OptionSet { // Live voice is only useful now; replaying stale audio frames via // sync would waste airtime (receivers drop them as stale anyway). case .voiceFrame: return nil + // Rotating-ID presence is valid only inside its epoch, and gossiping it + // would defeat the point: a synced announce would let a device that was + // never in radio range collect tag blocks, turning a local presence + // beacon into a network-wide one. + case .announceV2: return nil // Prekey bundles gossip like board posts. The bitfield is a // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits // ignored by `type(forBit:)`), so bits 8+ need no format change: old diff --git a/docs/PEER-ID-ROTATION.md b/docs/PEER-ID-ROTATION.md index d220cad7..24be1a55 100644 --- a/docs/PEER-ID-ROTATION.md +++ b/docs/PEER-ID-ROTATION.md @@ -1,8 +1,25 @@ # Peer ID Rotation Specification -**Status:** Draft for cross-platform review. Nothing here is implemented yet. +**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh. **Audience:** bitchat iOS and bitchat Android maintainers. -**Requires agreement before implementation.** This changes the wire protocol, so neither platform can ship it alone. +**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone. + +**Where the code is:** + +| Piece | File | +|---|---| +| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` | +| `announceV2 = 0x05` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` | +| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` | +| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` | + +The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4). + +Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this: + +- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one. +- **Not padded** (`BLEOutboundPacketPolicy`). At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol. The format is already near-constant width; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument. +- **Parsed but ignored** on receive (`BLEService`), as above. --- @@ -248,16 +265,34 @@ Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identit ## 7. Test vectors -To be filled in jointly **before** implementation, so both platforms verify against the same numbers rather than against each other's bugs. Each vector should give hex inputs and expected outputs for: +These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table. -1. `K_rot` from a fixed 32-byte Noise static private key. -2. `peerID_e` for that `K_rot` at three consecutive epochs. -3. `S_AB`, `K_AB`, `tag_AB` for a fixed key pair at a fixed epoch, computed from both sides, showing they agree. -4. A padded 8-slot tag list with two real tags, showing the real tags are recoverable regardless of position. -5. The §4.5 binding `proof` bytes and signature for fixed keys, ID, and epoch. -6. A full v2 announce packet, encoded, as a hex blob. +All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift. -Whichever platform writes a vector, the other MUST reproduce it independently from this document rather than from the first platform's code. +With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32): + +``` +rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: , + info: "bitchat-peer-rotation-v1", len: 32) + = fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09 + +peerID(epoch=100) = HMAC-SHA256(rotationSecret, + "bitchat-peer-id-v2" || uint32be(100))[0..8] + = f7c08c528506a374 +``` + +With a recognition key derived from a shared secret of 32 × `0x42`: + +``` +recognitionKey = HKDF-SHA256(ikm: 42×32, salt: , + info: "bitchat-recognition-v1", len: 32) +tag(epoch=100) = HMAC-SHA256(recognitionKey, uint32be(100))[0..8] + = 36400502fa59f4a9 +``` + +Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position. + +Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code. ## 8. Open questions for review diff --git a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift new file mode 100644 index 00000000..4dac2980 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift @@ -0,0 +1,155 @@ +// +// AnnounceV2Packet.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Identity-free presence announcement for rotating peer IDs. +/// +/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the +/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing +/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus +/// the local social graph, free to anyone in radio range. This carries none of +/// it — only an epoch, a fixed-size block of pairwise recognition tags, and +/// capability bits. +/// +/// Deliberately absent, with reasons: +/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX +/// handshake, where they are already encrypted on the wire. +/// - **Nickname**: a self-chosen, frequently reused human label. It moves into +/// the session (`AuthenticatedPeerStatePacket`). +/// - **Neighbour list**: it seeds source routing, whose documented fallback is +/// flooding. Publishing a crowd's adjacency graph is not a reasonable price +/// for routing efficiency. +/// +/// **Unsigned, on purpose and not without cost.** There is no key to verify a +/// signature against without disclosing one, so this asserts only "somebody is +/// here, and here are some tags". An attacker can therefore emit noise — bounded +/// by existing announce and connection rate limits — but cannot impersonate a +/// specific peer, because forging a recognition tag needs one of the two private +/// keys, and cannot send anything without completing a handshake. The intended +/// posture is to treat a v2 announce as *unverified presence* and not surface it +/// until a tag matches or a handshake completes. See open question O4 in +/// `docs/PEER-ID-ROTATION.md`. +/// +/// Not emitted or consumed by the shipping mesh yet. +public struct AnnounceV2Packet: Equatable, Sendable { + /// Rotation epoch this announce was built for. Carried explicitly so a + /// receiver matches against a stated epoch instead of guessing. + public let epoch: UInt32 + /// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes. + public let tagBlock: Data + public let capabilities: PeerCapabilities? + /// Coarse rendezvous cell, when bridging. Same semantics as v1. + public let bridgeGeohash: String? + + public init( + epoch: UInt32, + tagBlock: Data, + capabilities: PeerCapabilities? = nil, + bridgeGeohash: String? = nil + ) { + self.epoch = epoch + self.tagBlock = tagBlock + self.capabilities = capabilities + self.bridgeGeohash = bridgeGeohash + } + + private enum TLVType: UInt8 { + case epoch = 0x01 + case tagBlock = 0x02 + case capabilities = 0x03 + case bridgeGeohash = 0x04 + } + + /// Expected tag-block width. A fixed size is load-bearing: it hides how many + /// mutual favourites a device has. + public static var tagBlockLength: Int { + PeerIDRotation.tagSlots * PeerIDRotation.idLength + } + + public func encode() -> Data? { + guard tagBlock.count == Self.tagBlockLength else { return nil } + + var data = Data() + + data.append(TLVType.epoch.rawValue) + data.append(UInt8(4)) + withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) } + + data.append(TLVType.tagBlock.rawValue) + data.append(UInt8(tagBlock.count)) + data.append(tagBlock) + + if let capabilities { + let bytes = capabilities.encoded() + guard bytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(bytes.count)) + data.append(bytes) + } + + if let bridgeGeohash, !bridgeGeohash.isEmpty { + let bytes = Data(bridgeGeohash.utf8) + guard bytes.count <= 12 else { return nil } + data.append(TLVType.bridgeGeohash.rawValue) + data.append(UInt8(bytes.count)) + data.append(bytes) + } + + return data + } + + public static func decode(from data: Data) -> AnnounceV2Packet? { + var epoch: UInt32? + var tagBlock: Data? + var capabilities: PeerCapabilities? + var bridgeGeohash: String? + + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil } + let rawType = data[offset] + let length = Int(data[data.index(after: offset)]) + let valueStart = data.index(offset, offsetBy: 2) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let value = data.subdata(in: valueStart.. +// + +import Foundation +private import CryptoKit + +/// Derivations for rotating peer IDs and pairwise recognition tags. +/// +/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the +/// open questions. This type is the executable half of that document: it is +/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE +/// stack) so both platforms can agree on the numbers before anyone wires it +/// into a transport. +/// +/// Nothing here is used by the shipping mesh yet. +/// +/// ## Why the derivations look like this +/// +/// The rotating ID comes from **private** key material. Deriving it from the +/// public key would let anyone who has ever seen that key compute every past +/// and future ID, which is worse than not rotating because it would look like +/// protection. The same mistake is live in `CourierEnvelope.recipientTag`, +/// which is keyed on the recipient's *public* static key — and since that key +/// is broadcast in cleartext in every announce today, any observer in radio +/// range can compute a peer's courier tags for any day. +/// +/// Recognition tags come from the X25519 shared secret between two static +/// keys, so exactly two parties can compute a given tag and an observer can +/// compute none of them. +public enum PeerIDRotation { + // MARK: - Parameters + + /// Seconds per rotation epoch. One hour is a starting position, not a + /// settled one: shorter is less linkable but churns sessions, routes, and + /// in-flight fragment reassembly more often. See open question O1. + public static let rotationPeriod: TimeInterval = 3600 + + /// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte + /// header sender ID, so the packet layout is unchanged. + public static let idLength = 8 + + /// Fixed number of tag slots in an announce. Padding to a constant hides + /// how many mutual favourites a device has, which is itself identifying. + public static let tagSlots = 8 + + // MARK: - Context strings + // + // Distinct per use so a value derived for one purpose can never be + // substituted for another. `bitchat-announce-v1` is deliberately NOT reused: + // it belongs to the production-dead announce-signature helpers in + // NoiseEncryptionService, and confusing the two would be a real bug. + + private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8) + private static let peerIDContext = Data("bitchat-peer-id-v2".utf8) + private static let recognitionInfo = Data("bitchat-recognition-v1".utf8) + private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8) + + // MARK: - Epochs + + /// Epoch number for a point in time. Wall-clock derived so two devices that + /// have never met agree on the current epoch without negotiating. + public static func epoch(at date: Date) -> UInt32 { + let seconds = max(0, date.timeIntervalSince1970) + return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod)) + } + + /// Epochs to test when matching, oldest first. + /// + /// The ±1 window absorbs clock skew and the moment either side crosses a + /// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two + /// devices a few seconds apart across a boundary would fail to recognise + /// each other for no reason a person could understand. + public static func candidateEpochs(around date: Date) -> [UInt32] { + let current = epoch(at: date) + return current == 0 ? [0, 1] : [current - 1, current, current + 1] + } + + // MARK: - Rotating peer ID + + /// Long-lived rotation secret for this device. Derived from the Noise + /// static **private** key, so it never leaves the device and no observer + /// can predict any ID it produces. + public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data { + let derived = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey), + info: rotationInfo, + outputByteCount: 32 + ) + return derived.withUnsafeBytes { Data($0) } + } + + /// This device's peer ID for a given epoch. + public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data { + var message = peerIDContext + message.append(bigEndianBytes(epoch)) + let mac = HMAC.authenticationCode( + for: message, + using: SymmetricKey(data: rotationSecret) + ) + return Data(mac).prefix(idLength) + } + + /// Convenience: the ID this device should be using at `date`. + public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data { + peerID( + rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey), + epoch: epoch(at: date) + ) + } + + // MARK: - Pairwise recognition tags + + /// Symmetric recognition key for a pair, from their X25519 shared secret. + /// + /// Both sides compute the identical value from opposite key halves, which + /// is the whole point: recognition needs no round trip, and no third party + /// can derive it. + public static func recognitionKey(sharedSecret: Data) -> Data { + let derived = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: sharedSecret), + info: recognitionInfo, + outputByteCount: 32 + ) + return derived.withUnsafeBytes { Data($0) } + } + + /// The tag a peer holding this pair's recognition key expects this epoch. + public static func recognitionTag(recognitionKey: Data, epoch: UInt32) -> Data { + let mac = HMAC.authenticationCode( + for: bigEndianBytes(epoch), + using: SymmetricKey(data: recognitionKey) + ) + return Data(mac).prefix(idLength) + } + + // MARK: - Tag block + + /// Packs tags into the fixed-size announce block, padding with uniform + /// random bytes. + /// + /// Random padding is indistinguishable from a real tag to anyone who cannot + /// compute the real ones, so the block discloses neither how many mutual + /// favourites a device has nor which slot belongs to whom. Tags beyond + /// `tagSlots` are dropped here; choosing *which* to carry across successive + /// announces is the caller's problem (open question O2). + public static func tagBlock( + tags: [Data], + randomBytes: (Int) -> Data = Self.secureRandomBytes + ) -> Data { + var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) } + // Order must carry no information, so shuffle rather than appending + // real tags at the front. + slots.shuffle() + var block = Data() + for slot in slots { + block.append(slot) + if slot.count < idLength { + block.append(Data(repeating: 0, count: idLength - slot.count)) + } + } + let padding = (tagSlots - slots.count) * idLength + if padding > 0 { + block.append(randomBytes(padding)) + } + return block + } + + /// Splits a received block back into candidate tags. + /// + /// Returns nil for a block that is not exactly `tagSlots * idLength`, so a + /// malformed announce is rejected rather than partially interpreted. + public static func tags(fromBlock block: Data) -> [Data]? { + guard block.count == tagSlots * idLength else { return nil } + return stride(from: 0, to: block.count, by: idLength).map { + block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength)) + } + } + + /// Whether any slot in `block` matches a tag this pair expects at `date`. + /// + /// Comparison is constant-time per candidate, and every slot is examined + /// even after a match, so neither the presence of a match nor its slot + /// index is observable through timing. + public static func blockMatches( + _ block: Data, + recognitionKey: Data, + at date: Date + ) -> Bool { + guard let slots = tags(fromBlock: block) else { return false } + let expected = candidateEpochs(around: date).map { + recognitionTag(recognitionKey: recognitionKey, epoch: $0) + } + var matched = false + for slot in slots { + for candidate in expected where constantTimeEquals(slot, candidate) { + matched = true + } + } + return matched + } + + // MARK: - Identity binding + + /// Canonical bytes proving a rotating ID belongs to a static key. + /// + /// Signed with the Ed25519 identity key and exchanged **inside** a + /// completed Noise session, this replaces the derivation check that today + /// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked + /// in the announce preflight and again at handshake completion). Once IDs + /// are independent of the key, those checks fail for every peer, so a + /// replacement has to exist before rotation can ship. + /// + /// Fixed-width fields throughout: no length prefixes are needed and no two + /// distinct inputs can produce the same bytes. + public static func bindingMessage( + epoch: UInt32, + peerID: Data, + noiseStaticPublicKey: Data + ) -> Data { + var out = bindingContext + out.append(bigEndianBytes(epoch)) + out.append(fixedWidth(peerID, idLength)) + out.append(fixedWidth(noiseStaticPublicKey, 32)) + return out + } + + // MARK: - Helpers + + /// Padding must be indistinguishable from a real tag, so it comes from the + /// system CSPRNG via key generation rather than a general-purpose RNG. + public static func secureRandomBytes(_ count: Int) -> Data { + guard count > 0 else { return Data() } + let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8)) + return key.withUnsafeBytes { Data($0) } + } + + private static func bigEndianBytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + private static func fixedWidth(_ data: Data, _ width: Int) -> Data { + var out = data.prefix(width) + if out.count < width { + out.append(Data(repeating: 0, count: width - out.count)) + } + return Data(out) + } + + /// Length-independent comparison, so a match cannot be found byte by byte + /// through timing. + private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift new file mode 100644 index 00000000..31d95583 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +@testable import BitFoundation + +/// Wire-format tests for the identity-free announce. These are the second half +/// of the cross-platform contract: Android must encode and decode byte-identical +/// packets, so anything asserted here is a promise, not an implementation detail. +struct AnnounceV2PacketTests { + private var block: Data { + Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength) + } + + @Test func typeValueIsStable() { + // Changing this breaks every deployed decoder. 0x05 was free; 0x01-0x04, + // 0x10-0x11 and 0x20-0x29 were already taken. + #expect(MessageType.announceV2.rawValue == 0x05) + #expect(MessageType(rawValue: 0x05) == .announceV2) + #expect(MessageType.announceV2.description == "announceV2") + } + + @Test func tagBlockIsSixtyFourBytes() { + #expect(AnnounceV2Packet.tagBlockLength == 64) + } + + @Test func roundTripsWithEveryField() throws { + let packet = AnnounceV2Packet( + epoch: 495_555, + tagBlock: block, + capabilities: [.bridge, .prekeys], + bridgeGeohash: "u4pruy" + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) + #expect(decoded == packet) + } + + @Test func roundTripsWithOnlyRequiredFields() throws { + let packet = AnnounceV2Packet(epoch: 0, tagBlock: block) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) + #expect(decoded == packet) + #expect(decoded.capabilities == nil) + #expect(decoded.bridgeGeohash == nil) + } + + @Test func epochIsBigEndianOnTheWire() throws { + let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode()) + // TLV 0x01, length 4, then the epoch most-significant byte first. + #expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04]) + } + + /// The whole point of the format: none of the identifying v1 fields appear. + @Test func encodingCarriesNoIdentity() throws { + let noiseKey = Data(repeating: 0x11, count: 32) + let signingKey = Data(repeating: 0x22, count: 32) + let nickname = Data("alice".utf8) + + let encoded = try #require( + AnnounceV2Packet( + epoch: 100, + tagBlock: block, + capabilities: [.bridge], + bridgeGeohash: "u4pruy" + ).encode() + ) + + #expect(!encoded.contains(noiseKey)) + #expect(!encoded.contains(signingKey)) + #expect(encoded.range(of: nickname) == nil) + } + + @Test func encodingIsSmallerThanAV1Announce() throws { + let v2 = try #require( + AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode() + ) + // v1 with a 10-byte nickname and a full neighbour list, before its + // 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82 + // + capabilities 3. + let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3 + #expect(v2.count < v1PayloadEstimate) + } + + // MARK: - Rejection + + @Test func encodeRejectsAWrongWidthTagBlock() { + // A short block would disclose the favourite count, so it must never go + // on the wire. + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil) + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil) + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil) + } + + @Test func encodeRejectsAnOversizedGeohash() { + #expect(AnnounceV2Packet( + epoch: 1, + tagBlock: block, + bridgeGeohash: String(repeating: "u", count: 13) + ).encode() == nil) + } + + @Test func decodeRequiresEpochAndTagBlock() throws { + // Capabilities alone is not a valid announce. + var onlyCapabilities = Data([0x03, 0x01]) + onlyCapabilities.append(PeerCapabilities([.bridge]).encoded()) + #expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil) + + // Epoch without a tag block is not either. + let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + #expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil) + } + + @Test func decodeRejectsTruncatedAndMalformedInput() { + #expect(AnnounceV2Packet.decode(from: Data()) == nil) + // Declares 4 bytes, supplies 2. + #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil) + // Dangling type byte with no length. + #expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil) + // Wrong epoch width. + #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil) + } + + @Test func decodeRejectsAWrongWidthTagBlock() { + var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + data.append(0x02) + data.append(UInt8(63)) + data.append(Data(repeating: 0xAB, count: 63)) + #expect(AnnounceV2Packet.decode(from: data) == nil) + } + + @Test func decodeRejectsNonCanonicalCapabilities() throws { + // Same capability set, non-minimal encoding: it must not be accepted, or + // one set could travel as several distinct byte strings. + var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + data.append(0x02) + data.append(UInt8(AnnounceV2Packet.tagBlockLength)) + data.append(block) + data.append(0x03) + data.append(UInt8(3)) + data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal + #expect(AnnounceV2Packet.decode(from: data) == nil) + } + + @Test func unknownTLVsAreSkippedForForwardCompatibility() throws { + var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode()) + data.append(0x7F) // a type this build has never heard of + data.append(UInt8(3)) + data.append(Data([0x01, 0x02, 0x03])) + + let decoded = try #require(AnnounceV2Packet.decode(from: data)) + #expect(decoded.epoch == 100) + #expect(decoded.tagBlock == block) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift new file mode 100644 index 00000000..383756be --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift @@ -0,0 +1,313 @@ +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..