mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ab04d2bb | ||
|
|
c37e376568 | ||
|
|
51186c3be6 | ||
|
|
8df3871096 | ||
|
|
a8b741d605 | ||
|
|
a55a16adcd | ||
|
|
dbe11641ad | ||
|
|
ccf17326d2 | ||
|
|
385d32063f |
@@ -80,3 +80,4 @@ build.log
|
|||||||
|
|
||||||
# Local configs
|
# Local configs
|
||||||
Local.xcconfig
|
Local.xcconfig
|
||||||
|
*.profraw
|
||||||
|
|||||||
@@ -155,8 +155,30 @@ struct IdentityCache: Codable {
|
|||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
|
// 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
|
// Schema version for future migrations
|
||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// Custom decoding so caches written by older builds (without
|
||||||
|
// `cryptographicIdentities`) still load instead of being discarded.
|
||||||
|
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) ?? []
|
||||||
|
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||||
|
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -145,18 +145,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
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()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
// 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
|
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
// returns the write is already complete and NOTHING is left scheduled on
|
||||||
// no run loop, so saves never actually fired.)
|
// 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
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
@@ -216,7 +227,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
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
|
// MARK: - Secure Loading/Saving
|
||||||
@@ -241,21 +267,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||||
/// and persists it on the same serialized context — no timer, nothing left
|
/// while serialized. The encode + keychain write are done here (already on
|
||||||
/// scheduled to keep the process alive.
|
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||||
|
/// scheduled and nothing is left to keep the process alive.
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
pendingSave = true
|
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
|
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||||
/// (barrier) access.
|
///
|
||||||
private func performSave() {
|
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||||
guard pendingSave else { return }
|
/// under `queue` and then encode without holding it. Reading `cache`
|
||||||
pendingSave = false
|
/// 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
|
// Never persist under an ephemeral key — it would overwrite the real
|
||||||
// cache with data the next launch cannot decrypt.
|
// cache with data the next launch cannot decrypt.
|
||||||
guard !encryptionKeyIsEphemeral else {
|
guard !encryptionKeyIsEphemeral else {
|
||||||
@@ -264,7 +296,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(cache)
|
let data = try JSONEncoder().encode(snapshot)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||||
if saved {
|
if saved {
|
||||||
@@ -275,14 +307,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
// API already persists inline inside its own barrier via
|
||||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
// already up to date and this is normally a no-op; it exists as a
|
||||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
// 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() {
|
func forceSave() {
|
||||||
performSave()
|
queue.sync(flags: .barrier) {
|
||||||
|
guard pendingSave else { return }
|
||||||
|
pendingSave = false
|
||||||
|
persist(snapshot: cache)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
@@ -296,15 +340,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// MARK: - Cryptographic Identities
|
// MARK: - Cryptographic Identities
|
||||||
|
|
||||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||||
/// - noisePublicKey: Noise static public key data
|
/// - noisePublicKey: Noise static public key data
|
||||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
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
|
// Update keys if changed
|
||||||
if existing.publicKey != noisePublicKey {
|
if existing.publicKey != noisePublicKey {
|
||||||
existing = CryptographicIdentity(
|
existing = CryptographicIdentity(
|
||||||
@@ -314,7 +376,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key and lastHandshake
|
// Update signing key and lastHandshake
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
@@ -325,7 +387,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
self.cache.cryptographicIdentities[fingerprint] = updated
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -337,7 +399,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: now,
|
firstSeen: now,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally persist claimed nickname into social identity
|
// Optionally persist claimed nickname into social identity
|
||||||
@@ -369,12 +431,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.isShort else { return [] }
|
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) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
@@ -410,7 +472,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.isFavorite = isFavorite
|
identity.isFavorite = isFavorite
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
@@ -448,7 +510,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||||
|
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.isBlocked = isBlocked
|
identity.isBlocked = isBlocked
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
@@ -482,7 +544,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||||
let key = pubkeyHexLowercased.lowercased()
|
let key = pubkeyHexLowercased.lowercased()
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
self.cache.blockedNostrPubkeys.insert(key)
|
self.cache.blockedNostrPubkeys.insert(key)
|
||||||
} else {
|
} else {
|
||||||
@@ -499,7 +561,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
sessionStart: Date(),
|
sessionStart: Date(),
|
||||||
@@ -509,7 +571,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
// If handshake completed, update last interaction
|
// If handshake completed, update last interaction
|
||||||
@@ -525,10 +587,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func clearAllIdentityData() {
|
func clearAllIdentityData() {
|
||||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||||
|
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.cache = IdentityCache()
|
self.cache = IdentityCache()
|
||||||
self.ephemeralSessions.removeAll()
|
self.ephemeralSessions.removeAll()
|
||||||
self.cryptographicIdentities.removeAll()
|
|
||||||
|
|
||||||
// Delete from keychain
|
// Delete from keychain
|
||||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||||
@@ -537,7 +598,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,7 +608,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool) {
|
func setVerified(fingerprint: String, verified: Bool) {
|
||||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||||
|
|
||||||
queue.async(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
/// Noise and signing public keys already recorded for the peer, if any
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
/// (single registry read so both come from one consistent snapshot).
|
||||||
|
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||||
|
/// 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.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -23,13 +29,15 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
/// Runs the registry mutation phase under the collections barrier.
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
let withRegistryBarrier: (() -> Void) -> Void
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
/// Upserts the verified announce into the peer registry.
|
/// Upserts the verified announce into the peer registry.
|
||||||
|
/// Returns `nil` when the registry refuses the announce because it carries
|
||||||
|
/// a signing key different from the one already pinned for this peer.
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let upsertVerifiedAnnounce: (
|
let upsertVerifiedAnnounce: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
_ announcement: AnnouncementPacket,
|
_ announcement: AnnouncementPacket,
|
||||||
_ isConnected: Bool,
|
_ isConnected: Bool,
|
||||||
_ now: Date
|
_ now: Date
|
||||||
) -> BLEPeerAnnounceUpdate
|
) -> BLEPeerAnnounceUpdate?
|
||||||
/// Debounced reconnect-log decision.
|
/// Debounced reconnect-log decision.
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||||
@@ -99,7 +107,16 @@ final class BLEAnnounceHandler {
|
|||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
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 hasSignature = packet.signature != nil
|
||||||
let signatureValid: Bool
|
let signatureValid: Bool
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
@@ -113,13 +130,18 @@ final class BLEAnnounceHandler {
|
|||||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||||
|
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||||
|
announcedSigningPublicKey: announcement.signingPublicKey
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
}
|
}
|
||||||
let verifiedAnnounce = trustDecision.isVerified
|
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||||
|
}
|
||||||
|
var verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
@@ -139,12 +161,22 @@ final class BLEAnnounceHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let update = env.upsertVerifiedAnnounce(
|
// The registry re-checks the signing-key pin inside the barrier.
|
||||||
|
// The pre-barrier trust check reads the registry outside the
|
||||||
|
// barrier, so this closes the race where two announces for the
|
||||||
|
// same peer are evaluated concurrently.
|
||||||
|
guard let update = env.upsertVerifiedAnnounce(
|
||||||
peerID,
|
peerID,
|
||||||
announcement,
|
announcement,
|
||||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
now
|
now
|
||||||
)
|
) else {
|
||||||
|
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||||
|
verifiedAnnounce = false
|
||||||
|
isNewPeer = false
|
||||||
|
isReconnectedPeer = false
|
||||||
|
return
|
||||||
|
}
|
||||||
isNewPeer = update.isNewPeer
|
isNewPeer = update.isNewPeer
|
||||||
isReconnectedPeer = update.wasDisconnected
|
isReconnectedPeer = update.wasDisconnected
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
|
case signingKeyMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data
|
announcedNoisePublicKey: Data,
|
||||||
|
existingSigningPublicKey: Data?,
|
||||||
|
announcedSigningPublicKey: Data
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
return .reject(.keyMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOFU signing-key pinning. The packet signature only proves the
|
||||||
|
// announce is self-consistent — it is verified against the Ed25519 key
|
||||||
|
// carried *inside the same announce*. Since peerIDs derive from the
|
||||||
|
// broadcast (public) noise key, an attacker can replay a victim's
|
||||||
|
// peerID+noiseKey with their own signing key and a valid
|
||||||
|
// self-signature. Once we have bound a signing key to this peer,
|
||||||
|
// refuse to silently replace it.
|
||||||
|
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||||
|
return .reject(.signingKeyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
guard hasSignature else {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
peers[peerID] = peer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies a verified announce to the registry.
|
||||||
|
///
|
||||||
|
/// TOFU signing-key pinning: once a signing key has been bound to this
|
||||||
|
/// peer entry, an announce carrying a *different* signing key is refused
|
||||||
|
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
|
||||||
|
/// derived from the (public) noise key, so without pinning an attacker
|
||||||
|
/// could replay a victim's noiseKey/peerID with their own signing key and
|
||||||
|
/// silently take over the victim's mesh identity and nickname.
|
||||||
mutating func upsertVerifiedAnnounce(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate? {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
|
|
||||||
|
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||||
|
let announcedSigningKey = signingPublicKey,
|
||||||
|
pinnedSigningKey != announcedSigningKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
isNewPeer: existing == nil,
|
isNewPeer: existing == nil,
|
||||||
wasDisconnected: existing?.isConnected == false,
|
wasDisconnected: existing?.isConnected == false,
|
||||||
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
// Never drop an already-pinned signing key.
|
||||||
|
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3028,9 +3028,21 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
messageTTL: messageTTL,
|
messageTTL: messageTTL,
|
||||||
now: { Date() },
|
now: { Date() },
|
||||||
existingNoisePublicKey: { [weak self] peerID in
|
existingPeerKeys: { [weak self] peerID in
|
||||||
|
guard let self = self else { return (nil, 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 }
|
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
|
verifySignature: { [weak self] packet, signingPublicKey in
|
||||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
@@ -3043,14 +3055,17 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||||
// Called from inside withRegistryBarrier; access registry directly.
|
// Called from inside withRegistryBarrier; access registry directly.
|
||||||
self?.peerRegistry.upsertVerifiedAnnounce(
|
guard let self = self else {
|
||||||
|
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
|
}
|
||||||
|
return self.peerRegistry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now
|
now: now
|
||||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
|
var existingSigningPublicKey: Data?
|
||||||
|
var persistedSigningPublicKey: Data?
|
||||||
|
var persistedSigningKeyQueries: [PeerID] = []
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
var dedupSeenIDs: Set<String> = []
|
var dedupSeenIDs: Set<String> = []
|
||||||
var shouldEmitReconnectLogResult = true
|
var shouldEmitReconnectLogResult = true
|
||||||
|
|
||||||
@@ -35,7 +38,11 @@ struct BLEAnnounceHandlerTests {
|
|||||||
localPeerID: { localPeerID },
|
localPeerID: { localPeerID },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||||
|
persistedSigningPublicKey: { peerID in
|
||||||
|
recorder.persistedSigningKeyQueries.append(peerID)
|
||||||
|
return recorder.persistedSigningPublicKey
|
||||||
|
},
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
@@ -368,6 +375,168 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func matchingPinnedSigningKeyIsAccepted() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let noiseKey = Data(repeating: 0x9A, count: 32)
|
||||||
|
let peerID = PeerID(publicKey: noiseKey)
|
||||||
|
let packet = try makeAnnouncePacket(
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
peerID: peerID,
|
||||||
|
timestamp: timestamp(now),
|
||||||
|
signature: Data(repeating: 0xEE, count: 64)
|
||||||
|
)
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.existingNoisePublicKey = noiseKey
|
||||||
|
// Matches the signing key encoded by makeAnnouncePacket.
|
||||||
|
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
|
handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(recorder.upsertCalls.count == 1)
|
||||||
|
#expect(recorder.persistedIdentities.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let noiseKey = Data(repeating: 0x9B, count: 32)
|
||||||
|
let peerID = PeerID(publicKey: noiseKey)
|
||||||
|
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
|
||||||
|
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
|
||||||
|
let packet = try makeAnnouncePacket(
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
peerID: peerID,
|
||||||
|
timestamp: timestamp(now),
|
||||||
|
signature: Data(repeating: 0xEE, count: 64)
|
||||||
|
)
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.existingNoisePublicKey = noiseKey
|
||||||
|
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
|
||||||
|
recorder.signatureValid = true
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
|
handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
|
#expect(recorder.persistedIdentities.isEmpty)
|
||||||
|
#expect(recorder.topologyUpdates.isEmpty)
|
||||||
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func 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
|
@Test
|
||||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
@@ -391,6 +560,251 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
|
||||||
|
// Real crypto: the attacker crafts a fully self-consistent announce
|
||||||
|
// (victim's noiseKey/peerID, attacker's signing key and nickname,
|
||||||
|
// valid packet signature made with the attacker's key). Without
|
||||||
|
// signing-key pinning this used to overwrite the victim's registry
|
||||||
|
// entry and persisted identity.
|
||||||
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||||
|
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
final class RegistryBox {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
var persistedIdentities: [AnnouncementPacket] = []
|
||||||
|
}
|
||||||
|
let box = RegistryBox()
|
||||||
|
|
||||||
|
let environment = BLEAnnounceHandlerEnvironment(
|
||||||
|
localPeerID: { PeerID(str: "0102030405060708") },
|
||||||
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
|
now: { now },
|
||||||
|
existingPeerKeys: { peerID in
|
||||||
|
let info = box.registry.info(for: peerID)
|
||||||
|
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||||
|
},
|
||||||
|
persistedSigningPublicKey: { _ in nil },
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) },
|
||||||
|
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) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.barrierCount == 0)
|
#expect(recorder.barrierCount == 0)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: false,
|
hasSignature: false,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
existingNoisePublicKey: nil,
|
||||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.missingSignature))
|
#expect(decision == .reject(.missingSignature))
|
||||||
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
existingNoisePublicKey: nil,
|
||||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.invalidSignature))
|
#expect(decision == .reject(.invalidSignature))
|
||||||
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
||||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
|
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.keyMismatch))
|
#expect(decision == .reject(.keyMismatch))
|
||||||
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: noiseKey,
|
existingNoisePublicKey: noiseKey,
|
||||||
announcedNoisePublicKey: noiseKey
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: nil,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .verified)
|
#expect(decision == .verified)
|
||||||
#expect(decision.isVerified)
|
#expect(decision.isVerified)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
|
||||||
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
|
||||||
|
// Attacker replays the victim's noiseKey/peerID with their own signing
|
||||||
|
// key and a valid self-signature; the pinned key must win.
|
||||||
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: true,
|
||||||
|
signatureValid: true,
|
||||||
|
existingNoisePublicKey: noiseKey,
|
||||||
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decision == .reject(.signingKeyMismatch))
|
||||||
|
#expect(!decision.isVerified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func trustPolicyAcceptsMatchingPinnedSigningKey() {
|
||||||
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
let signingKey = Data(repeating: 0x99, count: 32)
|
||||||
|
|
||||||
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: true,
|
||||||
|
signatureValid: true,
|
||||||
|
existingNoisePublicKey: noiseKey,
|
||||||
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
existingSigningPublicKey: signingKey,
|
||||||
|
announcedSigningPublicKey: signingKey
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decision == .verified)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import Testing
|
|||||||
@Suite("BLE peer registry tests")
|
@Suite("BLE peer registry tests")
|
||||||
struct BLEPeerRegistryTests {
|
struct BLEPeerRegistryTests {
|
||||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||||
func upsertVerifiedAnnounceTracksTransitions() {
|
func upsertVerifiedAnnounceTracksTransitions() throws {
|
||||||
var registry = BLEPeerRegistry()
|
var registry = BLEPeerRegistry()
|
||||||
let peerID = PeerID(str: "1122334455667788")
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
let first = registry.upsertVerifiedAnnounce(
|
let firstResult = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice",
|
nickname: "alice",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen
|
now: firstSeen
|
||||||
)
|
)
|
||||||
|
let first = try #require(firstResult)
|
||||||
|
|
||||||
#expect(first.isNewPeer)
|
#expect(first.isNewPeer)
|
||||||
#expect(!first.wasDisconnected)
|
#expect(!first.wasDisconnected)
|
||||||
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||||
|
|
||||||
registry.markDisconnected(peerID)
|
registry.markDisconnected(peerID)
|
||||||
let reconnect = registry.upsertVerifiedAnnounce(
|
let reconnectResult = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice-renamed",
|
nickname: "alice-renamed",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen.addingTimeInterval(1)
|
now: firstSeen.addingTimeInterval(1)
|
||||||
)
|
)
|
||||||
|
let reconnect = try #require(reconnectResult)
|
||||||
|
|
||||||
#expect(!reconnect.isNewPeer)
|
#expect(!reconnect.isNewPeer)
|
||||||
#expect(reconnect.wasDisconnected)
|
#expect(reconnect.wasDisconnected)
|
||||||
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("pinned signing key cannot be silently replaced by a later announce")
|
||||||
|
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let victimSigningKey = Data(repeating: 0x42, count: 32)
|
||||||
|
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||||
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
let pinResult = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "victim",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: victimSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen
|
||||||
|
)
|
||||||
|
#expect(pinResult != nil)
|
||||||
|
|
||||||
|
// Attacker replays the victim's noiseKey/peerID with their own
|
||||||
|
// signing key and nickname; the upsert must be refused wholesale.
|
||||||
|
let attack = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "attacker",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: attackerSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(attack == nil)
|
||||||
|
let info = try #require(registry.info(for: peerID))
|
||||||
|
#expect(info.nickname == "victim")
|
||||||
|
#expect(info.signingPublicKey == victimSigningKey)
|
||||||
|
|
||||||
|
// A legitimate re-announce with the pinned key is still accepted.
|
||||||
|
let legit = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "victim-renamed",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: victimSigningKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(2)
|
||||||
|
)
|
||||||
|
#expect(legit != nil)
|
||||||
|
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("announce without a signing key keeps the pinned key")
|
||||||
|
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let signingKey = Data(repeating: 0x42, count: 32)
|
||||||
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
let initialResult = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: signingKey,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen
|
||||||
|
)
|
||||||
|
#expect(initialResult != nil)
|
||||||
|
|
||||||
|
let update = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(update != nil)
|
||||||
|
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
let offlinePeer = PeerID(str: "1122334455667788")
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
|||||||
@@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
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 {
|
func test_setBlocked_clearsFavoriteState() async {
|
||||||
let manager = SecureIdentityStateManager(MockKeychain())
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
let fingerprint = String(repeating: "ab", count: 32)
|
let fingerprint = String(repeating: "ab", count: 32)
|
||||||
|
|||||||
Reference in New Issue
Block a user