mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 06:05:21 +00:00
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>
This commit is contained in:
@@ -133,10 +133,42 @@ public enum PeerIDRotation {
|
||||
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 {
|
||||
/// The tag `sender` puts in its announce for `recipient` this epoch.
|
||||
///
|
||||
/// Three inputs beyond the epoch, each load-bearing:
|
||||
///
|
||||
/// - **Ordered keys make the tag directional.** An earlier draft used
|
||||
/// `HMAC(K_AB, epoch)`, which is symmetric — so A and B broadcast the
|
||||
/// *same* 8 bytes, and an observer who spots one value in two different
|
||||
/// announces learns those two devices are mutual favourites and can link
|
||||
/// their rotating IDs to each other. That hands over exactly the social
|
||||
/// graph this design exists to hide. Ordering the keys gives A→B and B→A
|
||||
/// distinct values; both parties can still compute both directions,
|
||||
/// because both hold both public keys.
|
||||
/// - **`peerID` binds the tag to the announce carrying it.** Without it the
|
||||
/// tag depends only on (pair, epoch), so an attacker could lift a tag out
|
||||
/// of A's announce and replay it under an ID of their choosing; the
|
||||
/// recipient would match and believe that ID is A. Binding means a lifted
|
||||
/// tag is only valid alongside A's own ID, which reduces the attack from
|
||||
/// impersonation-as-any-ID to replaying A's presence.
|
||||
///
|
||||
/// Replaying A's own announce within the epoch window remains possible —
|
||||
/// unsigned announces cannot prevent it. Recognition is therefore a hint
|
||||
/// only, and anything consequential must wait for a completed handshake.
|
||||
/// See open question O4.
|
||||
public static func recognitionTag(
|
||||
recognitionKey: Data,
|
||||
epoch: UInt32,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: Data
|
||||
) -> Data {
|
||||
var message = bigEndianBytes(epoch)
|
||||
message.append(fixedWidth(senderStaticPublicKey, 32))
|
||||
message.append(fixedWidth(recipientStaticPublicKey, 32))
|
||||
message.append(fixedWidth(peerID, idLength))
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: bigEndianBytes(epoch),
|
||||
for: message,
|
||||
using: SymmetricKey(data: recognitionKey)
|
||||
)
|
||||
return Data(mac).prefix(idLength)
|
||||
@@ -185,7 +217,13 @@ public enum PeerIDRotation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any slot in `block` matches a tag this pair expects at `date`.
|
||||
/// Whether any slot in `block` holds the tag we expect a specific peer to
|
||||
/// have put there, for an announce carrying `peerID`.
|
||||
///
|
||||
/// `senderStaticPublicKey` is the peer we hope sent this (so we compute the
|
||||
/// direction they would use) and `recipientStaticPublicKey` is our own.
|
||||
/// Passing them the other way round tests the opposite direction and will
|
||||
/// not match, which is the point of making tags directional.
|
||||
///
|
||||
/// 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
|
||||
@@ -193,11 +231,20 @@ public enum PeerIDRotation {
|
||||
public static func blockMatches(
|
||||
_ block: Data,
|
||||
recognitionKey: Data,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: 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)
|
||||
recognitionTag(
|
||||
recognitionKey: recognitionKey,
|
||||
epoch: $0,
|
||||
senderStaticPublicKey: senderStaticPublicKey,
|
||||
recipientStaticPublicKey: recipientStaticPublicKey,
|
||||
peerID: peerID
|
||||
)
|
||||
}
|
||||
var matched = false
|
||||
for slot in slots {
|
||||
|
||||
@@ -99,6 +99,10 @@ struct PeerIDRotationTests {
|
||||
|
||||
// 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 {
|
||||
@@ -115,19 +119,80 @@ struct PeerIDRotationTests {
|
||||
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)
|
||||
// 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)
|
||||
let next = PeerIDRotation.recognitionTag(recognitionKey: key, epoch: 101)
|
||||
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))[0..8]
|
||||
#expect(hex(now) == "36400502fa59f4a9")
|
||||
// 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() {
|
||||
@@ -135,8 +200,13 @@ struct PeerIDRotationTests {
|
||||
// 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))
|
||||
#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
|
||||
@@ -190,49 +260,74 @@ struct PeerIDRotationTests {
|
||||
|
||||
// MARK: - Matching
|
||||
|
||||
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
|
||||
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)
|
||||
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, at: date))
|
||||
#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))
|
||||
|
||||
// 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
|
||||
))
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
))
|
||||
// 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 date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x99, count: 32))
|
||||
let (key, _, date) = matchFixture()
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
PeerIDRotation.tagBlock(tags: []),
|
||||
recognitionKey: key,
|
||||
at: date
|
||||
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user