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
@@ -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))
}
}