mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05: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; the registry/persisted identity was then overwritten unconditionally, enabling mesh nickname spoofing and forged attribution of signed public/broadcast messages. Fix: TOFU-pin the Ed25519 signing key per peer (noise-key-derived peerID) on the announce path. - BLEAnnounceTrustPolicy rejects announces whose signing key differs from the one already recorded for the peer (.signingKeyMismatch). - BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned signing key (returns nil) and never drops a pinned key. - BLEAnnounceHandler falls back to the persisted cryptographic identity (persistedSigningPublicKey) when the registry has no signing key, so the pin survives registry eviction and app restart. - SecureIdentityStateManager.upsertCryptographicIdentity refuses to replace a persisted signing key with a different one, and the cryptographic identities (incl. the pin) now live in the encrypted, persisted IdentityCache with synchronous, teardown-safe saves. Rebased onto main and integrated with #1432 (Noise session identity binding + signed leaves): both are complementary. #1432's signed-leave verification reads the same registry/persisted signing key that this change protects from announce-path poisoning. main's evolution is kept: BLEPeerRegistry.upsertVerifiedAnnounce still takes capabilities/ bridgeGeohash (nil return propagates as a refusal), the handler's linkBoundToOtherPeer env is preserved, CryptographicIdentity keeps main's field set, and EphemeralIdentity uses main's initializer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -80,3 +80,4 @@ build.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
*.profraw
|
||||
|
||||
@@ -161,24 +161,24 @@ struct VouchRecord: Codable, Equatable {
|
||||
struct IdentityCache: Codable {
|
||||
// Fingerprint -> Social mapping
|
||||
var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
|
||||
// Nickname -> [Fingerprints] reverse index
|
||||
// Multiple fingerprints can claim same nickname
|
||||
var nicknameIndex: [String: Set<String>] = [:]
|
||||
|
||||
|
||||
// Verified fingerprints (cryptographic proof)
|
||||
var verifiedFingerprints: Set<String> = []
|
||||
|
||||
|
||||
// Last interaction timestamps (privacy: optional)
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
// caches persisted before this feature decode cleanly — decodeIfPresent
|
||||
// is used below, and a missing key must not trip the "unreadable cache"
|
||||
// recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
@@ -189,6 +189,36 @@ struct IdentityCache: Codable {
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
|
||||
// Persisting the signing-key pin is security-critical: it must survive
|
||||
// app restarts so an attacker cannot replay a known peer's
|
||||
// noiseKey/peerID with their own signing key and be treated as first
|
||||
// contact (TOFU downgrade).
|
||||
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
|
||||
init() {}
|
||||
|
||||
// Custom decoding so caches written by older builds (missing newer keys
|
||||
// such as `cryptographicIdentities` or the vouching fields) still load
|
||||
// instead of being discarded. Every field uses decodeIfPresent so a
|
||||
// missing key falls back to its default rather than throwing.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
|
||||
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -152,18 +152,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
// Cryptographic identities (including pinned signing keys) live inside
|
||||
// `cache` so they persist across app restarts; see IdentityCache.
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||
// no run loop, so saves never actually fired.)
|
||||
//
|
||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||
// returns the write is already complete and NOTHING is left scheduled on
|
||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
||||
// original design) kept the dispatch machinery alive and prevented the
|
||||
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
|
||||
// (a later design) left a backlog of instrumented barrier saves still
|
||||
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
|
||||
// `.profraw`, deadlocking the process at teardown on the constrained CI
|
||||
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
|
||||
// neither failure mode is possible. `pendingSave` is now effectively always
|
||||
// false after any mutation (saveIdentityCache persists inline and clears
|
||||
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
|
||||
// and `deinit`.
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
@@ -223,7 +234,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
forceSave()
|
||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
||||
// (including one draining `queue`), and the object is being
|
||||
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
|
||||
// (deadlock) and a `queue.async` schedules work that resurrects `self`
|
||||
// and may not drain before process exit.
|
||||
//
|
||||
// A flush here is redundant anyway: every mutating API already
|
||||
// persists inline within its own barrier, so the keychain is already
|
||||
// up to date. As a queue-free best-effort belt-and-suspenders, only
|
||||
// flush if something is still pending. This is a direct read of
|
||||
// in-hand state — safe because a deallocating object has no other
|
||||
// live references, so nothing can be mutating `cache` concurrently.
|
||||
if pendingSave {
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
@@ -248,21 +274,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||
/// while serialized. The encode + keychain write are done here (already on
|
||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||
/// scheduled and nothing is left to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
performSave()
|
||||
// On the barrier context already: snapshot is trivially consistent.
|
||||
persist(snapshot: cache)
|
||||
pendingSave = false
|
||||
}
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||
///
|
||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||
/// under `queue` and then encode without holding it. Reading `cache`
|
||||
/// concurrently with a barrier writer would be a data race on the
|
||||
/// dictionary storage, which — because `JSONEncoder` walks that storage —
|
||||
/// can spin forever (observed as a CI test-suite hang), so the snapshot
|
||||
/// must be taken on `queue`, never off it.
|
||||
private func persist(snapshot: IdentityCache) {
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
@@ -271,7 +303,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
@@ -282,14 +314,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||
// API already persists inline inside its own barrier via
|
||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||
// already up to date and this is normally a no-op; it exists as a
|
||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
||||
//
|
||||
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
|
||||
// makes the `cache` read race-free (a plain off-queue read races in-flight
|
||||
// barrier writers — JSONEncoder walking a concurrently-mutated dictionary
|
||||
// can spin forever, which surfaced as a CI hang), and being synchronous it
|
||||
// leaves nothing scheduled to keep the process alive at teardown. Safe
|
||||
// against re-entrant deadlock because this is never invoked from `deinit`
|
||||
// (the only path that can run *on* `queue`).
|
||||
func forceSave() {
|
||||
performSave()
|
||||
queue.sync(flags: .barrier) {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -303,15 +347,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been persisted for a
|
||||
/// fingerprint, an update carrying a *different* signing key is refused in
|
||||
/// full (including the claimed-nickname update) and security-logged. This
|
||||
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an
|
||||
/// attacker replaying a victim's noiseKey/peerID with their own signing
|
||||
/// key could overwrite the victim's persisted identity while the victim is
|
||||
/// offline or after an app restart. The refusal is permanent: there is
|
||||
/// currently no targeted in-app way to reset the pin (`setVerified` does
|
||||
/// not touch it). Recovering from a legitimate signing re-key requires the
|
||||
/// peer to establish a new noise identity (new peerID) or the local user
|
||||
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if let pinnedSigningKey = existing.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
|
||||
return
|
||||
}
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
@@ -320,11 +382,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@@ -335,7 +397,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
@@ -367,12 +429,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
@@ -408,7 +470,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isFavorite = isFavorite
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
@@ -446,7 +508,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
if isBlocked {
|
||||
@@ -480,7 +542,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
let key = pubkeyHexLowercased.lowercased()
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if isBlocked {
|
||||
self.cache.blockedNostrPubkeys.insert(key)
|
||||
} else {
|
||||
@@ -503,7 +565,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
// If handshake completed, update last interaction
|
||||
@@ -519,11 +581,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
@@ -531,7 +592,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
@@ -541,7 +602,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
@@ -709,7 +770,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
|
||||
@@ -14,8 +14,14 @@ 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?)
|
||||
/// Signing key from the persisted cryptographic identity for the peer, if
|
||||
/// any. Registry pins do not survive app restarts or offline-peer
|
||||
/// eviction; this fallback keeps the TOFU signing-key pin effective for
|
||||
/// returning peers.
|
||||
let persistedSigningPublicKey: (PeerID) -> 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).
|
||||
@@ -29,13 +35,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
|
||||
@@ -115,7 +123,16 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
if existingPeerKeys.signingPublicKey == nil {
|
||||
// The registry entry (and its signing-key pin) is dropped on app
|
||||
// restart and offline-peer eviction, but the persisted
|
||||
// cryptographic identity survives both. Fall back to it so a
|
||||
// returning peer is not treated as first contact — otherwise an
|
||||
// attacker could replay the peer's noiseKey/peerID with their own
|
||||
// signing key and re-pin the identity (TOFU downgrade).
|
||||
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
|
||||
}
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@@ -129,13 +146,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
|
||||
@@ -172,12 +194,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,
|
||||
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,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,
|
||||
@@ -183,8 +191,15 @@ struct BLEPeerRegistry {
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = [],
|
||||
bridgeGeohash: String? = nil
|
||||
) -> 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,
|
||||
@@ -196,7 +211,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,
|
||||
capabilities: capabilities,
|
||||
|
||||
@@ -5461,9 +5461,21 @@ extension BLEService {
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
existingNoisePublicKey: { [weak self] peerID in
|
||||
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)
|
||||
}
|
||||
},
|
||||
persistedSigningPublicKey: { [weak self] peerID in
|
||||
// Same synchronous identity-manager read pattern as
|
||||
// signedSenderDisplayName(for:from:); the manager serializes
|
||||
// access on its own internal queue.
|
||||
guard let self = self else { return nil }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
},
|
||||
verifySignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
@@ -5495,16 +5507,24 @@ 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,
|
||||
// Propagate `nil` (registry refused the announce because it
|
||||
// carries a signing key different from the pinned one) so
|
||||
// the handler's guard rejects it instead of overwriting the
|
||||
// pinned identity. Main's capabilities/bridgeGeohash are
|
||||
// preserved.
|
||||
now: now,
|
||||
capabilities: announcement.capabilities ?? [],
|
||||
bridgeGeohash: announcement.bridgeGeohash
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||
|
||||
@@ -6,10 +6,13 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var existingSigningPublicKey: Data?
|
||||
var persistedSigningPublicKey: Data?
|
||||
var persistedSigningKeyQueries: [PeerID] = []
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var linkBoundToOtherPeer = 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
|
||||
|
||||
@@ -36,7 +39,11 @@ struct BLEAnnounceHandlerTests {
|
||||
localPeerID: { localPeerID },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||
persistedSigningPublicKey: { peerID in
|
||||
recorder.persistedSigningKeyQueries.append(peerID)
|
||||
return recorder.persistedSigningPublicKey
|
||||
},
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
@@ -482,6 +489,168 @@ 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 persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
|
||||
// Registry has no entry (app restart or offline-peer eviction), but
|
||||
// the persisted cryptographic identity still pins the victim's
|
||||
// signing key. An attacker replaying the victim's noiseKey/peerID
|
||||
// with their own signing key must not be treated as first contact.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9D, 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 = nil
|
||||
recorder.existingSigningPublicKey = nil
|
||||
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries == [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 persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
|
||||
// Legitimate returning peer: registry entry evicted, persisted pin
|
||||
// matches the announced signing key — accepted like a normal announce.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9E, 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()
|
||||
// Matches the signing key encoded by makeAnnouncePacket.
|
||||
recorder.persistedSigningPublicKey = 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 registryPinnedSigningKeySkipsPersistedLookup() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9F, 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
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries.isEmpty)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -506,6 +675,253 @@ 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)
|
||||
},
|
||||
persistedSigningPublicKey: { _ in nil },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
linkBoundToOtherPeer: { _, _ in 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
|
||||
// Real crypto + real persistence: the victim announces and gets
|
||||
// pinned, then the registry entry disappears (offline-peer eviction
|
||||
// via reconcileConnectivity, or app restart which starts with an
|
||||
// empty registry). The attacker replays the victim's
|
||||
// noiseKey/peerID with their own signing key and a valid
|
||||
// self-signature — the persisted identity must still block the
|
||||
// takeover, and must not be overwritten. The victim (same signing
|
||||
// key) must be re-accepted.
|
||||
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||
let now = Date()
|
||||
|
||||
let identityKeychain = MockKeychain()
|
||||
let identityManager = SecureIdentityStateManager(identityKeychain)
|
||||
|
||||
final class RegistryBox {
|
||||
var registry = BLEPeerRegistry()
|
||||
}
|
||||
let box = RegistryBox()
|
||||
|
||||
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
|
||||
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)
|
||||
},
|
||||
// Mirrors the BLEService wiring: fall back to the persisted
|
||||
// cryptographic identity.
|
||||
persistedSigningPublicKey: { peerID in
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
},
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
linkBoundToOtherPeer: { _, _ in 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
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
},
|
||||
dedupContains: { _ in true },
|
||||
dedupMarkProcessed: { _ in },
|
||||
deliverAnnounceUIEvents: { _, _, _ in },
|
||||
trackPacketSeen: { _ in },
|
||||
sendAnnounceBack: {},
|
||||
scheduleAfterglow: { _ in }
|
||||
)
|
||||
}
|
||||
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func persistedIdentity() -> CryptographicIdentity? {
|
||||
// queue.sync read; fences the manager's pending barrier writes.
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
|
||||
}
|
||||
|
||||
// 1. Victim announces: pinned in the registry and persisted.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 2. Registry entry disappears (eviction / restart).
|
||||
_ = box.registry.remove(peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
|
||||
// 3. Attacker replay with own signing key: rejected via the persisted
|
||||
// pin, and neither the registry nor the persisted identity change.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
|
||||
|
||||
// 4. Victim re-announces with the same signing key: accepted again.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 5. Simulated app restart: a fresh identity manager reloads the pin
|
||||
// from the (mock) keychain, and a fresh registry starts empty. The
|
||||
// attacker replay is still rejected.
|
||||
identityManager.forceSave()
|
||||
let reloadedManager = SecureIdentityStateManager(identityKeychain)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
box.registry = BLEPeerRegistry()
|
||||
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
// ...while the victim is accepted after the restart.
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x22, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
|
||||
// Attacker upsert with a different signing key must be refused in
|
||||
// full — signing key AND claimed nickname stay the victim's.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
|
||||
// Synchronous reads fence the manager's pending barrier writes.
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
|
||||
// The legitimate peer (same signing key) can still update.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim-renamed"
|
||||
)
|
||||
let renamed = await waitUntil {
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
|
||||
}
|
||||
XCTAssertTrue(renamed)
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
}
|
||||
|
||||
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let noisePublicKey = Data(repeating: 0x13, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x24, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x77, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
manager.forceSave()
|
||||
|
||||
// Simulated app restart: the pin must survive and still refuse a
|
||||
// different signing key.
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
|
||||
reloaded.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
}
|
||||
|
||||
func test_setBlocked_clearsFavoriteState() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
Reference in New Issue
Block a user