mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-15 07:06:11 +00:00
* docs: specify peer ID rotation for cross-platform review Draft protocol spec for review by both iOS and Android before any implementation. Nothing here is implemented; this is the artifact to agree on, since the change is a wire revision neither platform can ship alone. The headline correction, because it is easy to get wrong: rotating the peer ID alone accomplishes nothing. The announce carries the Noise static key, the Ed25519 signing key and the nickname in cleartext, so a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together. The second thing an implementer needs to know up front is that peerID == SHA-256(noiseStaticKey)[0..8] is not a convention, it is the mechanism that makes peer IDs unforgeable, enforced in the announce preflight and again at handshake completion. Making IDs independent of the key fails both checks for every peer, so a replacement binding has to ship in the same change. The spec proposes one: an Ed25519 proof over (context, epoch, rotating ID, static key) carried inside the completed Noise session via the existing AuthenticatedPeerStatePacket, checked against a pinned signing key — strictly stronger than today's self-signed announce. Design summary: hour-epoch IDs derived from private key material via HKDF+HMAC so no observer can predict or link them; pairwise recognition tags from the X25519 shared secret so mutual favourites still recognise each other with no handshake, padded to fixed slots so the tag count does not leak how many favourites someone has; strangers discovered by handshake-first-identify-second over Noise XX, whose static keys are already encrypted on the wire. Nickname moves inside the session and the neighbour list is dropped rather than rotated. Includes a verified impact inventory separating what breaks hard (the handshake check, the announce preflight, the disk outbox keyed by peer ID, private-media stable IDs and their deletion tombstones, the initiator tie-break, fingerprint-prefix lookups) from what degrades gracefully and what is already safe because it keys on fingerprints or Noise keys. Rollout uses the two mechanisms already proven in this repo: a PeerCapabilities bit (11 is next; 10 is burned) with capabilitiesWereExplicitlyAdvertised to tell an old client from a new one with the bit off, and observed-version gating as used for source routing. Two findings surfaced while writing this and are recorded in the spec. CourierEnvelope.recipientTag is HMAC keyed on the recipient's *public* static key, and since that key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day — so the whitepaper's "cannot link it across days" does not currently hold, and the pattern must not be copied. And NoiseEncryptionService's buildAnnounceSignature/verifyAnnounceSignature/canonicalAnnounceBytes are present but production-dead, called only from tests; the binding above deliberately uses a different context string so the two can never be confused. Eight open questions are left explicitly unresolved, including the rotation period, whether unsigned v2 announces are an acceptable posture, and whether Android's decoder tolerates trailing bytes the way iOS's does (which decides whether padding coverage can ship ungated). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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> * Mark the rotation primitives periphery:ignore The dead-code scan correctly flagged both new types as unused, which they intentionally are: they exist to be reviewed and argued with before the protocol change they belong to can ship. Annotated in place rather than added to .periphery.baseline.json so the reason sits next to the code and disappears with it, following the existing convention in MessageRouter. Both notes say to delete the annotation once the mesh starts using the type. `periphery scan --strict` locally: no unused code detected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix two P1 flaws in the recognition tag design (Codex #1487) Both findings are correct and both were real. This is the argument for shipping code next to the prose: neither was obvious in the design text. **Tags were symmetric, which leaked the social graph.** `HMAC(K_AB, epoch)` produces the same 8 bytes for both parties, so an observer who saw one value in two different announces would learn those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over exactly the graph the design exists to hide, plus a cross-epoch correlation handle. Tags are now directional: the MAC covers the ordered sender and recipient static public keys, so A→B and B→A differ. Both parties can still compute both directions because both hold both keys. **Tags were replayable under any ID.** A tag depending only on (pair, epoch) could be lifted from a recorded announce and replayed in a fresh announce under an attacker-chosen ID; the recipient would match and treat that ID as the favourite, and since epoch-1 is accepted it would keep working into the next period. The MAC now covers the announced peer ID, which reduces this to replaying the victim's own presence. That residual is unfixable while announces are unsigned, so the spec now states plainly that recognition is a hint only: presence may be populated, but routing a DM or showing a verified badge must wait for a handshake whose static key equals the favourite that produced the match. O4 is rewritten around that, with the two alternatives named (per-epoch ephemeral signing key, or a freshness nonce echoed by the recipient). Tests: two regression cases named for the findings, plus a wrong-direction-does-not-match case so the directional fix cannot silently become cosmetic. The vector table now gives both directions, because their difference is the security property — an implementation that produces one value for both has reintroduced the flaw. Recomputed independently in Python from the spec and matched byte for byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record that padding changes are cross-platform coordinated O7 began as a question about whether Android tolerates trailing bytes. The firmer answer, found while attempting the padding fix unilaterally: toBinaryDataForSigning encodes with padding enabled, so the padding bytes are inside the signed material for every signed packet. Changing the algorithm changes the signed byte stream and breaks verification against any peer that has not made the identical change. So both outstanding padding fixes — coverage beyond Noise frames, and the gap where a frame needing over 255 bytes of padding ships unpadded — are wire changes requiring both platforms, not local cleanups. O7 now says so, and names the two things to settle. Also updates the related-work section: dropping the neighbour list and randomizing origin TTL did turn out to be unilateral and have landed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the review findings on the rotation spec **P1 — the binding proof was replayable onto another session.** The §4.5 verifier checklist omitted the check that the proof's noiseStaticPublicKey equals the remote static key the Noise session actually established. The proof is a self-contained signed blob with nothing tying it to the session it arrives on, so a peer M that had seen A's proof could replay it verbatim inside M's own session with B; B would verify A's signature, see a well-formed binding, and on first contact TOFU-pin A's signing key against M's fingerprint. Added as the first item in the checklist, with the attack written out, because "signed" and "bound to this conversation" are different properties and the difference is easy to lose in a bullet list. **announceV2 is 0x2C, not 0x05.** 0x05 only looks free. It has been recycled twice — announce, then bulkTransferResponse, then fragmentStart until #446 — so an old peer could still map it to a fragment header and misparse presence as a partial message. Values above voiceFrame = 0x29 have only ever been allocated forward, and 0x2A/0x2B belong to the courier spray-ack work, leaving 0x2C. Confirmed never used anywhere in this repository's history. **Outbound priority is now stated, not inherited.** announceV2 fell through to `default: .high`. High is the right answer — presence is small, time-bounded to its epoch and useless once stale — but for a type nothing emits yet, a fall-through means the choice gets made without anyone seeing it. **Reverted unexplained pbxproj churn.** Xcode had rewritten resource-phase ordering and dropped a share-extension entitlements membership exception; none of it belongs in this PR. The file now matches main byte for byte. **O7 said "payloads" where the arithmetic is over encoded frames.** The 241-256 / 497-768 / 1009-1792 ranges are what `pad` receives, which is the whole encoded packet, not the payload alone. **Added O9: a seized device recomputes every past peer ID.** K_rot is long-lived, so peerID_e is computable for any epoch by whoever holds it — someone who seizes a phone, or pulls the static key from a backup, can go back over historical radio captures and identify which were this device. Rotation defends against the passive observer, not against later key compromise. A hash ratchet would give forward secrecy for the ID stream at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — a real trade rather than an obvious win, so it is written down as a question rather than silently adopted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: rotation capability bit is 14 now — 11-13 claimed by in-flight work Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
409 lines
18 KiB
Swift
409 lines
18 KiB
Swift
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
|
|
|
|
private var pubA: Data { Data(repeating: 0x0A, count: 32) }
|
|
private var pubB: Data { Data(repeating: 0x0B, count: 32) }
|
|
private var idA: Data { Data(repeating: 0xA1, count: 8) }
|
|
|
|
/// 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)
|
|
|
|
// A emits its A->B tag; B computes the same value to look for it.
|
|
let emitted = PeerIDRotation.recognitionTag(
|
|
recognitionKey: keyA, epoch: 100,
|
|
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
|
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
|
peerID: idA
|
|
)
|
|
let expected = PeerIDRotation.recognitionTag(
|
|
recognitionKey: keyB, epoch: 100,
|
|
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
|
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
|
peerID: idA
|
|
)
|
|
#expect(emitted == expected)
|
|
#expect(emitted.count == PeerIDRotation.idLength)
|
|
}
|
|
|
|
/// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the
|
|
/// identical 8 bytes, so an observer who sees one value in two announces
|
|
/// learns those two are mutual favourites and can link their rotating IDs.
|
|
/// Tags must therefore differ by direction.
|
|
@Test func recognitionTagsAreDirectional() {
|
|
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
|
let aToB = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 100,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
)
|
|
let bToA = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 100,
|
|
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA
|
|
)
|
|
#expect(aToB != bToA)
|
|
}
|
|
|
|
/// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted
|
|
/// from someone's announce could be replayed under an attacker-chosen ID and
|
|
/// the recipient would accept that ID as the favourite.
|
|
@Test func recognitionTagIsBoundToTheAnnouncedPeerID() {
|
|
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
|
let real = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 100,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
)
|
|
let underAttackerID = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 100,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
|
peerID: Data(repeating: 0xFF, count: 8)
|
|
)
|
|
#expect(real != underAttackerID)
|
|
|
|
// And the lifted tag must not verify against the attacker's ID.
|
|
let block = PeerIDRotation.tagBlock(tags: [real])
|
|
#expect(!PeerIDRotation.blockMatches(
|
|
block, recognitionKey: key,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
|
peerID: Data(repeating: 0xFF, count: 8),
|
|
at: Date(timeIntervalSince1970: 3600 * 100)
|
|
))
|
|
}
|
|
|
|
@Test func recognitionTagRotatesWithTheEpoch() {
|
|
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
|
let now = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 100,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
)
|
|
let next = PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: 101,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
)
|
|
#expect(now != next)
|
|
// VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"),
|
|
// uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8]
|
|
#expect(hex(now) == "4568f61d61d6cbfb")
|
|
}
|
|
|
|
@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,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
) != PeerIDRotation.recognitionTag(
|
|
recognitionKey: other, epoch: 7,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
))
|
|
}
|
|
|
|
// 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
|
|
|
|
private func matchFixture() -> (key: Data, tag: Data, date: Date) {
|
|
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),
|
|
senderStaticPublicKey: pubA,
|
|
recipientStaticPublicKey: pubB,
|
|
peerID: idA
|
|
)
|
|
return (key, tag, date)
|
|
}
|
|
|
|
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
|
|
let (key, tag, date) = matchFixture()
|
|
// 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,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
|
peerID: idA, at: date
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Testing the wrong direction must fail, or the directional fix would be
|
|
/// cosmetic.
|
|
@Test func blockDoesNotMatchTheOppositeDirection() {
|
|
let (key, tag, date) = matchFixture()
|
|
let block = PeerIDRotation.tagBlock(tags: [tag])
|
|
#expect(!PeerIDRotation.blockMatches(
|
|
block, recognitionKey: key,
|
|
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA,
|
|
peerID: idA, at: date
|
|
))
|
|
}
|
|
|
|
@Test func blockMatchesToleratesTheEpochBoundary() {
|
|
let date = Date(timeIntervalSince1970: 3600 * 100)
|
|
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
|
|
|
|
func tag(epoch: UInt32) -> Data {
|
|
PeerIDRotation.recognitionTag(
|
|
recognitionKey: key, epoch: epoch,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
|
)
|
|
}
|
|
func matches(_ candidate: Data) -> Bool {
|
|
PeerIDRotation.blockMatches(
|
|
PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
|
peerID: idA, at: date
|
|
)
|
|
}
|
|
|
|
// A peer whose clock has already ticked over still matches.
|
|
#expect(matches(tag(epoch: 101)))
|
|
// Two epochs out is outside the window and must not.
|
|
#expect(!matches(tag(epoch: 98)))
|
|
}
|
|
|
|
@Test func randomBlockDoesNotMatch() {
|
|
let (key, _, date) = matchFixture()
|
|
#expect(!PeerIDRotation.blockMatches(
|
|
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
|
|
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
|
peerID: idA, 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))
|
|
}
|
|
}
|