mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +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
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Noise and signing public keys already recorded for the peer, if any
|
||||
/// (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.
|
||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Direct link state for the peer (BLE-queue read).
|
||||
@@ -23,13 +24,15 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// 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`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
) -> BLEPeerAnnounceUpdate?
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
@@ -99,7 +102,7 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
let existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@@ -113,13 +116,18 @@ final class BLEAnnounceHandler {
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||
announcedSigningPublicKey: announcement.signingPublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
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 isReconnectedPeer = false
|
||||
@@ -139,12 +147,22 @@ final class BLEAnnounceHandler {
|
||||
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,
|
||||
announcement,
|
||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
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
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case signingKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
announcedNoisePublicKey: Data,
|
||||
existingSigningPublicKey: Data?,
|
||||
announcedSigningPublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
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 {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
|
||||
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(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
) -> BLEPeerAnnounceUpdate? {
|
||||
let existing = peers[peerID]
|
||||
|
||||
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
isNewPeer: existing == nil,
|
||||
wasDisconnected: existing?.isConnected == false,
|
||||
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
)
|
||||
|
||||
@@ -3028,9 +3028,12 @@ extension BLEService {
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
existingNoisePublicKey: { [weak self] peerID in
|
||||
guard let self = self else { return nil }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||
existingPeerKeys: { [weak self] peerID in
|
||||
guard let self = self else { return (nil, nil) }
|
||||
return self.collectionsQueue.sync {
|
||||
let info = self.peerRegistry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
}
|
||||
},
|
||||
verifySignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
@@ -3043,14 +3046,17 @@ extension BLEService {
|
||||
},
|
||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||
// 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,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||
|
||||
@@ -6,9 +6,10 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var existingSigningPublicKey: Data?
|
||||
var signatureValid = true
|
||||
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 shouldEmitReconnectLogResult = true
|
||||
|
||||
@@ -35,7 +36,7 @@ struct BLEAnnounceHandlerTests {
|
||||
localPeerID: { localPeerID },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
@@ -368,6 +369,88 @@ struct BLEAnnounceHandlerTests {
|
||||
#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
|
||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -391,6 +474,106 @@ struct BLEAnnounceHandlerTests {
|
||||
#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) {
|
||||
#expect(recorder.barrierCount == 0)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
|
||||
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: false,
|
||||
signatureValid: false,
|
||||
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))
|
||||
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: false,
|
||||
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))
|
||||
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
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))
|
||||
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
#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
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
|
||||
@@ -6,12 +6,12 @@ import Testing
|
||||
@Suite("BLE peer registry tests")
|
||||
struct BLEPeerRegistryTests {
|
||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||
func upsertVerifiedAnnounceTracksTransitions() {
|
||||
func upsertVerifiedAnnounceTracksTransitions() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let first = registry.upsertVerifiedAnnounce(
|
||||
let firstResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
let first = try #require(firstResult)
|
||||
|
||||
#expect(first.isNewPeer)
|
||||
#expect(!first.wasDisconnected)
|
||||
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||
|
||||
registry.markDisconnected(peerID)
|
||||
let reconnect = registry.upsertVerifiedAnnounce(
|
||||
let reconnectResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice-renamed",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
let reconnect = try #require(reconnectResult)
|
||||
|
||||
#expect(!reconnect.isNewPeer)
|
||||
#expect(reconnect.wasDisconnected)
|
||||
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
|
||||
#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")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
Reference in New Issue
Block a user