mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:05:20 +00:00
Pin announce signing keys to stop mesh identity spoofing
BLE announce trust verified the packet signature against the Ed25519 signing key carried inside the same announce, and the trust policy only rejected on noise-key mismatch. Since peerIDs derive from the broadcast (public) noise key, an on-mesh attacker could replay a victim's peerID+noiseKey with their own signing key, nickname, and a valid self-signature; BLEPeerRegistry.upsertVerifiedAnnounce then overwrote the victim's entry unconditionally. That enabled mesh nickname spoofing and, via the persisted identity, forged attribution of signed public/broadcast messages. Fix: TOFU-pin the signing key per peer (noise-key-derived peerID). - BLEAnnounceTrustPolicy now rejects announces whose signing key differs from the one already recorded for the peer (.signingKeyMismatch) and logs a security event. - BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned signing key (returns nil), closing the race where the pre-barrier trust check reads the registry outside the collections barrier. It also never drops a pinned key when an announce omits one. - BLEAnnounceHandler skips registry upsert, topology updates, and identity persistence for rejected announces. No wire-format change: the packet signature already covers senderID, timestamp, and the full announce payload (noise key, signing key, nickname), so mixed-version meshes are unaffected. First contact for an unknown peer behaves exactly as before (trust on first use); the pin is scoped to the registry entry lifetime, so a legitimately re-keyed identity recovers after normal peer eviction. Tests: trust-policy signing-key mismatch/match cases, registry pinning (attacker upsert refused, legitimate re-announce accepted, omitted key keeps pin), handler-level pinned-key rejection, and an end-to-end test with real Ed25519 keys showing a fully self-consistent attacker announce cannot displace the victim's pinned identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,9 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
/// Noise and signing public keys already recorded for the peer, if any
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
/// (single registry read so both come from one consistent snapshot).
|
||||||
|
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||||
/// Verifies the packet signature against the announced signing key.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -23,13 +24,15 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
/// Runs the registry mutation phase under the collections barrier.
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
let withRegistryBarrier: (() -> Void) -> Void
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
/// Upserts the verified announce into the peer registry.
|
/// Upserts the verified announce into the peer registry.
|
||||||
|
/// Returns `nil` when the registry refuses the announce because it carries
|
||||||
|
/// a signing key different from the one already pinned for this peer.
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let upsertVerifiedAnnounce: (
|
let upsertVerifiedAnnounce: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
_ announcement: AnnouncementPacket,
|
_ announcement: AnnouncementPacket,
|
||||||
_ isConnected: Bool,
|
_ isConnected: Bool,
|
||||||
_ now: Date
|
_ now: Date
|
||||||
) -> BLEPeerAnnounceUpdate
|
) -> BLEPeerAnnounceUpdate?
|
||||||
/// Debounced reconnect-log decision.
|
/// Debounced reconnect-log decision.
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||||
@@ -99,7 +102,7 @@ final class BLEAnnounceHandler {
|
|||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
let existingPeerKeys = env.existingPeerKeys(peerID)
|
||||||
let hasSignature = packet.signature != nil
|
let hasSignature = packet.signature != nil
|
||||||
let signatureValid: Bool
|
let signatureValid: Bool
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
@@ -113,13 +116,18 @@ final class BLEAnnounceHandler {
|
|||||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||||
|
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||||
|
announcedSigningPublicKey: announcement.signingPublicKey
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
}
|
}
|
||||||
let verifiedAnnounce = trustDecision.isVerified
|
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||||
|
}
|
||||||
|
var verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
@@ -139,12 +147,22 @@ final class BLEAnnounceHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let update = env.upsertVerifiedAnnounce(
|
// The registry re-checks the signing-key pin inside the barrier.
|
||||||
|
// The pre-barrier trust check reads the registry outside the
|
||||||
|
// barrier, so this closes the race where two announces for the
|
||||||
|
// same peer are evaluated concurrently.
|
||||||
|
guard let update = env.upsertVerifiedAnnounce(
|
||||||
peerID,
|
peerID,
|
||||||
announcement,
|
announcement,
|
||||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
now
|
now
|
||||||
)
|
) else {
|
||||||
|
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||||
|
verifiedAnnounce = false
|
||||||
|
isNewPeer = false
|
||||||
|
isReconnectedPeer = false
|
||||||
|
return
|
||||||
|
}
|
||||||
isNewPeer = update.isNewPeer
|
isNewPeer = update.isNewPeer
|
||||||
isReconnectedPeer = update.wasDisconnected
|
isReconnectedPeer = update.wasDisconnected
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
|
case signingKeyMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data
|
announcedNoisePublicKey: Data,
|
||||||
|
existingSigningPublicKey: Data?,
|
||||||
|
announcedSigningPublicKey: Data
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
return .reject(.keyMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOFU signing-key pinning. The packet signature only proves the
|
||||||
|
// announce is self-consistent — it is verified against the Ed25519 key
|
||||||
|
// carried *inside the same announce*. Since peerIDs derive from the
|
||||||
|
// broadcast (public) noise key, an attacker can replay a victim's
|
||||||
|
// peerID+noiseKey with their own signing key and a valid
|
||||||
|
// self-signature. Once we have bound a signing key to this peer,
|
||||||
|
// refuse to silently replace it.
|
||||||
|
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||||
|
return .reject(.signingKeyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
guard hasSignature else {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
peers[peerID] = peer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies a verified announce to the registry.
|
||||||
|
///
|
||||||
|
/// TOFU signing-key pinning: once a signing key has been bound to this
|
||||||
|
/// peer entry, an announce carrying a *different* signing key is refused
|
||||||
|
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
|
||||||
|
/// derived from the (public) noise key, so without pinning an attacker
|
||||||
|
/// could replay a victim's noiseKey/peerID with their own signing key and
|
||||||
|
/// silently take over the victim's mesh identity and nickname.
|
||||||
mutating func upsertVerifiedAnnounce(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate? {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
|
|
||||||
|
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||||
|
let announcedSigningKey = signingPublicKey,
|
||||||
|
pinnedSigningKey != announcedSigningKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
isNewPeer: existing == nil,
|
isNewPeer: existing == nil,
|
||||||
wasDisconnected: existing?.isConnected == false,
|
wasDisconnected: existing?.isConnected == false,
|
||||||
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
// Never drop an already-pinned signing key.
|
||||||
|
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3028,9 +3028,12 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
messageTTL: messageTTL,
|
messageTTL: messageTTL,
|
||||||
now: { Date() },
|
now: { Date() },
|
||||||
existingNoisePublicKey: { [weak self] peerID in
|
existingPeerKeys: { [weak self] peerID in
|
||||||
guard let self = self else { return nil }
|
guard let self = self else { return (nil, nil) }
|
||||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
return self.collectionsQueue.sync {
|
||||||
|
let info = self.peerRegistry.info(for: peerID)
|
||||||
|
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
verifySignature: { [weak self] packet, signingPublicKey in
|
verifySignature: { [weak self] packet, signingPublicKey in
|
||||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
@@ -3043,14 +3046,17 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||||
// Called from inside withRegistryBarrier; access registry directly.
|
// Called from inside withRegistryBarrier; access registry directly.
|
||||||
self?.peerRegistry.upsertVerifiedAnnounce(
|
guard let self = self else {
|
||||||
|
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
|
}
|
||||||
|
return self.peerRegistry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now
|
now: now
|
||||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
|
var existingSigningPublicKey: Data?
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
var dedupSeenIDs: Set<String> = []
|
var dedupSeenIDs: Set<String> = []
|
||||||
var shouldEmitReconnectLogResult = true
|
var shouldEmitReconnectLogResult = true
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
localPeerID: { localPeerID },
|
localPeerID: { localPeerID },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
@@ -368,6 +369,88 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func matchingPinnedSigningKeyIsAccepted() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let noiseKey = Data(repeating: 0x9A, count: 32)
|
||||||
|
let peerID = PeerID(publicKey: noiseKey)
|
||||||
|
let packet = try makeAnnouncePacket(
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
peerID: peerID,
|
||||||
|
timestamp: timestamp(now),
|
||||||
|
signature: Data(repeating: 0xEE, count: 64)
|
||||||
|
)
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.existingNoisePublicKey = noiseKey
|
||||||
|
// Matches the signing key encoded by makeAnnouncePacket.
|
||||||
|
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
|
handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(recorder.upsertCalls.count == 1)
|
||||||
|
#expect(recorder.persistedIdentities.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let noiseKey = Data(repeating: 0x9B, count: 32)
|
||||||
|
let peerID = PeerID(publicKey: noiseKey)
|
||||||
|
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
|
||||||
|
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
|
||||||
|
let packet = try makeAnnouncePacket(
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
peerID: peerID,
|
||||||
|
timestamp: timestamp(now),
|
||||||
|
signature: Data(repeating: 0xEE, count: 64)
|
||||||
|
)
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.existingNoisePublicKey = noiseKey
|
||||||
|
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
|
||||||
|
recorder.signatureValid = true
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
|
handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
|
#expect(recorder.persistedIdentities.isEmpty)
|
||||||
|
#expect(recorder.topologyUpdates.isEmpty)
|
||||||
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let noiseKey = Data(repeating: 0x9C, count: 32)
|
||||||
|
let peerID = PeerID(publicKey: noiseKey)
|
||||||
|
let packet = try makeAnnouncePacket(
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
peerID: peerID,
|
||||||
|
timestamp: timestamp(now),
|
||||||
|
signature: Data(repeating: 0xEE, count: 64),
|
||||||
|
directNeighbors: [Data(repeating: 0xAB, count: 8)]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
|
||||||
|
// but the registry itself refuses to replace its pinned signing key.
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.upsertResult = nil
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
|
handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(recorder.upsertCalls.count == 1)
|
||||||
|
#expect(recorder.persistedIdentities.isEmpty)
|
||||||
|
#expect(recorder.topologyUpdates.isEmpty)
|
||||||
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
|
#expect(recorder.afterglowDelays.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
@@ -391,6 +474,106 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
|
||||||
|
// Real crypto: the attacker crafts a fully self-consistent announce
|
||||||
|
// (victim's noiseKey/peerID, attacker's signing key and nickname,
|
||||||
|
// valid packet signature made with the attacker's key). Without
|
||||||
|
// signing-key pinning this used to overwrite the victim's registry
|
||||||
|
// entry and persisted identity.
|
||||||
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||||
|
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
final class RegistryBox {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
var persistedIdentities: [AnnouncementPacket] = []
|
||||||
|
}
|
||||||
|
let box = RegistryBox()
|
||||||
|
|
||||||
|
let environment = BLEAnnounceHandlerEnvironment(
|
||||||
|
localPeerID: { PeerID(str: "0102030405060708") },
|
||||||
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
|
now: { now },
|
||||||
|
existingPeerKeys: { peerID in
|
||||||
|
let info = box.registry.info(for: peerID)
|
||||||
|
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||||
|
},
|
||||||
|
verifySignature: { packet, signingPublicKey in
|
||||||
|
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||||
|
},
|
||||||
|
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||||
|
withRegistryBarrier: { body in body() },
|
||||||
|
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||||
|
box.registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: announcement.nickname,
|
||||||
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
|
isConnected: isConnected,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shouldEmitReconnectLog: { _, _ in false },
|
||||||
|
updateTopology: { _, _ in },
|
||||||
|
persistIdentity: { announcement in
|
||||||
|
box.persistedIdentities.append(announcement)
|
||||||
|
},
|
||||||
|
dedupContains: { _ in true },
|
||||||
|
dedupMarkProcessed: { _ in },
|
||||||
|
deliverAnnounceUIEvents: { _, _, _ in },
|
||||||
|
trackPacketSeen: { _ in },
|
||||||
|
sendAnnounceBack: {},
|
||||||
|
scheduleAfterglow: { _ in }
|
||||||
|
)
|
||||||
|
let handler = BLEAnnounceHandler(environment: environment)
|
||||||
|
|
||||||
|
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
|
||||||
|
let announcement = AnnouncementPacket(
|
||||||
|
nickname: nickname,
|
||||||
|
noisePublicKey: victimNoiseKey,
|
||||||
|
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||||
|
directNeighbors: nil
|
||||||
|
)
|
||||||
|
let payload = try #require(announcement.encode())
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
return try #require(signer.signPacket(packet))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legitimate announce from the victim is accepted and pinned.
|
||||||
|
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
|
||||||
|
handler.handle(victimAnnounce, from: peerID)
|
||||||
|
|
||||||
|
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||||
|
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||||
|
#expect(box.persistedIdentities.count == 1)
|
||||||
|
|
||||||
|
// Attacker announce with a valid self-signature must be rejected.
|
||||||
|
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
|
||||||
|
handler.handle(attackerAnnounce, from: peerID)
|
||||||
|
|
||||||
|
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||||
|
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||||
|
#expect(box.persistedIdentities.count == 1)
|
||||||
|
|
||||||
|
// The victim's subsequent announces (same pinned key) still work.
|
||||||
|
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
|
||||||
|
handler.handle(victimRename, from: peerID)
|
||||||
|
|
||||||
|
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||||
|
#expect(box.persistedIdentities.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.barrierCount == 0)
|
#expect(recorder.barrierCount == 0)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: false,
|
hasSignature: false,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
existingNoisePublicKey: nil,
|
||||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.missingSignature))
|
#expect(decision == .reject(.missingSignature))
|
||||||
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
existingNoisePublicKey: nil,
|
||||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.invalidSignature))
|
#expect(decision == .reject(.invalidSignature))
|
||||||
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
||||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.keyMismatch))
|
#expect(decision == .reject(.keyMismatch))
|
||||||
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: noiseKey,
|
existingNoisePublicKey: noiseKey,
|
||||||
announcedNoisePublicKey: noiseKey
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .verified)
|
#expect(decision == .verified)
|
||||||
#expect(decision.isVerified)
|
#expect(decision.isVerified)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
|
||||||
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
|
||||||
|
// Attacker replays the victim's noiseKey/peerID with their own signing
|
||||||
|
// key and a valid self-signature; the pinned key must win.
|
||||||
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: true,
|
||||||
|
signatureValid: true,
|
||||||
|
existingNoisePublicKey: noiseKey,
|
||||||
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decision == .reject(.signingKeyMismatch))
|
||||||
|
#expect(!decision.isVerified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func trustPolicyAcceptsMatchingPinnedSigningKey() {
|
||||||
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
let signingKey = Data(repeating: 0x99, count: 32)
|
||||||
|
|
||||||
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: true,
|
||||||
|
signatureValid: true,
|
||||||
|
existingNoisePublicKey: noiseKey,
|
||||||
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: signingKey,
|
||||||
|
announcedSigningPublicKey: signingKey
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decision == .verified)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import Testing
|
|||||||
@Suite("BLE peer registry tests")
|
@Suite("BLE peer registry tests")
|
||||||
struct BLEPeerRegistryTests {
|
struct BLEPeerRegistryTests {
|
||||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||||
func upsertVerifiedAnnounceTracksTransitions() {
|
func upsertVerifiedAnnounceTracksTransitions() throws {
|
||||||
var registry = BLEPeerRegistry()
|
var registry = BLEPeerRegistry()
|
||||||
let peerID = PeerID(str: "1122334455667788")
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
let first = registry.upsertVerifiedAnnounce(
|
let firstResult = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice",
|
nickname: "alice",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen
|
now: firstSeen
|
||||||
)
|
)
|
||||||
|
let first = try #require(firstResult)
|
||||||
|
|
||||||
#expect(first.isNewPeer)
|
#expect(first.isNewPeer)
|
||||||
#expect(!first.wasDisconnected)
|
#expect(!first.wasDisconnected)
|
||||||
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||||
|
|
||||||
registry.markDisconnected(peerID)
|
registry.markDisconnected(peerID)
|
||||||
let reconnect = registry.upsertVerifiedAnnounce(
|
let reconnectResult = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice-renamed",
|
nickname: "alice-renamed",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen.addingTimeInterval(1)
|
now: firstSeen.addingTimeInterval(1)
|
||||||
)
|
)
|
||||||
|
let reconnect = try #require(reconnectResult)
|
||||||
|
|
||||||
#expect(!reconnect.isNewPeer)
|
#expect(!reconnect.isNewPeer)
|
||||||
#expect(reconnect.wasDisconnected)
|
#expect(reconnect.wasDisconnected)
|
||||||
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("pinned signing key cannot be silently replaced by a later announce")
|
||||||
|
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let victimSigningKey = Data(repeating: 0x42, count: 32)
|
||||||
|
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||||
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
let pinResult = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "victim",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: victimSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen
|
||||||
|
)
|
||||||
|
#expect(pinResult != nil)
|
||||||
|
|
||||||
|
// Attacker replays the victim's noiseKey/peerID with their own
|
||||||
|
// signing key and nickname; the upsert must be refused wholesale.
|
||||||
|
let attack = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "attacker",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: attackerSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(attack == nil)
|
||||||
|
let info = try #require(registry.info(for: peerID))
|
||||||
|
#expect(info.nickname == "victim")
|
||||||
|
#expect(info.signingPublicKey == victimSigningKey)
|
||||||
|
|
||||||
|
// A legitimate re-announce with the pinned key is still accepted.
|
||||||
|
let legit = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "victim-renamed",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: victimSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(2)
|
||||||
|
)
|
||||||
|
#expect(legit != nil)
|
||||||
|
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("announce without a signing key keeps the pinned key")
|
||||||
|
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let signingKey = Data(repeating: 0x42, count: 32)
|
||||||
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
let initialResult = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: signingKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen
|
||||||
|
)
|
||||||
|
#expect(initialResult != nil)
|
||||||
|
|
||||||
|
let update = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(update != nil)
|
||||||
|
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
let offlinePeer = PeerID(str: "1122334455667788")
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
|||||||
Reference in New Issue
Block a user