Implement the peer ID rotation primitives

Code is a better thing to argue with than prose, so the spec now has a
working, tested base under it. Every number and context string is a
concrete proposal you can reject by changing one function and watching a
test vector move.

What is implemented:

- PeerIDRotation: hour epochs with a ±1 matching window, the rotation
  secret from the Noise static *private* key, per-epoch peer IDs, pairwise
  recognition keys and tags from an X25519 shared secret, the fixed-width
  tag block with CSPRNG padding and constant-time matching, and the
  canonical bytes for the identity binding.
- AnnounceV2Packet (announceV2 = 0x05): TLV wire format carrying an epoch,
  a 64-byte tag block, capabilities and an optional bridge cell — and
  nothing else. No nickname, no public keys, no neighbour list. Rejects a
  wrong-width tag block on both encode and decode, since a short block
  would disclose how many mutual favourites someone has, and rejects
  non-canonical capability encodings the way AuthenticatedPeerStatePacket
  does. Unknown TLVs are skipped for forward compatibility.
- 37 tests, three of which are hex vectors cross-checked against an
  independent implementation written from the spec alone (Python
  hmac/hashlib, HKDF extract-then-expand, empty salt) and matching byte
  for byte. That is the property Android needs: the document is sufficient
  to reproduce the numbers without reading this code.

What is deliberately NOT implemented: nothing emits a v2 announce, and
BLEService parses the type and explicitly ignores it. Consuming presence
needs both the replacement identity binding and a decision on how
unverified presence appears in the peer list, and accepting it now would
put unauthenticated entries in front of people.

Adding the message type forced three policy decisions, all reviewable:

- Not gossip-synced. Syncing presence would defeat the point — a device
  never in radio range could collect tag blocks, turning a local beacon
  into a network-wide one.
- Not padded. 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, and fixing the capability and geohash field widths
  would be cheaper than padding.
- Parsed but ignored on receive, as above.

Notably the v2 announce is *smaller* than v1 (~75 vs ~229 bytes): dropping
two 32-byte keys, the neighbour list and the signature more than pays for
64 bytes of tags, so unlinkability here costs less airtime rather than
more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 19:54:34 +02:00
co-authored by Claude Opus 5
parent dc783ad3ca
commit 10f1e5c8b7
9 changed files with 963 additions and 12 deletions
@@ -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
}
}
+10 -1
View File
@@ -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)
+5
View File
@@ -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
+45 -10
View File
@@ -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: <empty>,
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: <empty>,
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
@@ -0,0 +1,155 @@
//
// AnnounceV2Packet.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Identity-free presence announcement for rotating peer IDs.
///
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 430 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..<data.index(valueStart, offsetBy: length))
switch TLVType(rawValue: rawType) {
case .epoch:
guard length == 4 else { return nil }
epoch = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
case .tagBlock:
guard length == tagBlockLength else { return nil }
tagBlock = value
case .capabilities:
let decoded = PeerCapabilities(encoded: value)
// Canonicality check, matching AuthenticatedPeerStatePacket: a
// non-minimal encoding would let the same capability set travel
// as different bytes.
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .bridgeGeohash:
guard length <= 12, let text = String(data: value, encoding: .utf8) else { return nil }
bridgeGeohash = text
case nil:
// Unknown TLV: skip, for forward compatibility.
break
}
offset = data.index(valueStart, offsetBy: length)
}
guard let epoch, let tagBlock else { return nil }
return AnnounceV2Packet(
epoch: epoch,
tagBlock: tagBlock,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
@@ -15,6 +15,14 @@ public enum MessageType: UInt8 {
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
/// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed
/// block of pairwise recognition tags, and capabilities no nickname, no
/// public keys, no neighbour list. A separate type rather than a version of
/// `announce` because that decoder hard-requires the identity TLVs, so
/// omitting them is a parse failure rather than a graceful degrade.
/// Not emitted or consumed by the shipping mesh yet; see
/// `docs/PEER-ID-ROTATION.md`.
case announceV2 = 0x05
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
@@ -43,6 +51,7 @@ public enum MessageType: UInt8 {
public var description: String {
switch self {
case .announce: return "announce"
case .announceV2: return "announceV2"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
@@ -0,0 +1,264 @@
//
// PeerIDRotation.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<SHA256>.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<SHA256>.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<SHA256>.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<SHA256>.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
}
}
@@ -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)
}
}
@@ -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..<count).map { Data(repeating: UInt8($0 + 1), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == expected)
}
}
/// A device with one favourite and a device with six must be
/// indistinguishable from the block, or the block leaks social-graph size.
@Test func tagBlockHidesHowManyFavouritesThereAre() {
let one = PeerIDRotation.tagBlock(tags: [Data(repeating: 0xAA, count: 8)])
let six = PeerIDRotation.tagBlock(
tags: (1...6).map { Data(repeating: UInt8($0), count: 8) }
)
#expect(one.count == six.count)
}
@Test func tagBlockDropsOverflowRatherThanGrowing() {
let tags = (1...(PeerIDRotation.tagSlots + 5)).map { Data(repeating: UInt8($0), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == PeerIDRotation.tagSlots * 8)
}
@Test func padOnlyBlockUsesFreshRandomnessEachTime() {
// Repeated identical padding would make an empty block recognisable.
let first = PeerIDRotation.tagBlock(tags: [])
let second = PeerIDRotation.tagBlock(tags: [])
#expect(first != second)
}
@Test func tagsRoundTripThroughTheBlock() throws {
let real = Data(repeating: 0xC3, count: 8)
let block = PeerIDRotation.tagBlock(
tags: [real],
randomBytes: { Data(repeating: 0x00, count: $0) }
)
let slots = try #require(PeerIDRotation.tags(fromBlock: block))
#expect(slots.count == PeerIDRotation.tagSlots)
#expect(slots.contains(real))
}
@Test func malformedBlockIsRejectedRatherThanPartiallyRead() {
#expect(PeerIDRotation.tags(fromBlock: Data()) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 7)) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 65)) == nil)
}
// MARK: - Matching
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32))
let tag = PeerIDRotation.recognitionTag(
recognitionKey: key,
epoch: PeerIDRotation.epoch(at: date)
)
// Slot order must not matter, so assert across many shuffles.
for _ in 0..<20 {
let block = PeerIDRotation.tagBlock(tags: [tag])
#expect(PeerIDRotation.blockMatches(block, recognitionKey: key, at: date))
}
}
@Test func blockMatchesToleratesTheEpochBoundary() {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
// A peer whose clock has already ticked over still matches.
let nextEpochTag = PeerIDRotation.recognitionTag(recognitionKey: key, epoch: 101)
#expect(PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: [nextEpochTag]),
recognitionKey: key,
at: date
))
// Two epochs out is outside the window and must not match.
let staleTag = PeerIDRotation.recognitionTag(recognitionKey: key, epoch: 98)
#expect(!PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: [staleTag]),
recognitionKey: key,
at: date
))
}
@Test func randomBlockDoesNotMatch() {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x99, count: 32))
#expect(!PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: []),
recognitionKey: key,
at: date
))
}
// MARK: - Identity binding
@Test func bindingMessageIsFixedWidthAndContextSeparated() {
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let context = Data("bitchat-peerid-binding-v1".utf8)
#expect(message.count == context.count + 4 + 8 + 32)
#expect(message.starts(with: context))
// Must not collide with the production-dead announce-signature helpers,
// which use "bitchat-announce-v1".
#expect(!message.starts(with: Data("bitchat-announce-v1".utf8)))
}
@Test func bindingMessagePadsShortInputsRatherThanShifting() {
// Fixed-width fields mean a short ID cannot shift the key into the ID's
// position and produce a message that verifies for the wrong pairing.
let short = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]),
noiseStaticPublicKey: Data([0x02])
)
let padded = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]) + Data(repeating: 0, count: 7),
noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31)
)
#expect(short == padded)
}
@Test func bindingMessageChangesWithEveryField() {
let base = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
)
#expect(base != PeerIDRotation.bindingMessage(
epoch: 2,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x03, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x04, count: 32)
))
}
@Test func bindingMessageVerifiesUnderTheIdentityKey() throws {
let signing = Curve25519.Signing.PrivateKey()
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let signature = try signing.signature(for: message)
#expect(signing.publicKey.isValidSignature(signature, for: message))
// A different epoch must not verify: replaying a binding into a later
// epoch is exactly what this prevents.
let other = PeerIDRotation.bindingMessage(
epoch: 101,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
#expect(!signing.publicKey.isValidSignature(signature, for: other))
}
}