Compare commits

..
Author SHA1 Message Date
jackandGitHub 41486fd1c9 Merge branch 'main' into chore/perf-privacy-polish 2026-07-01 23:58:27 +02:00
jackandClaude Fable 5 d8527b98fd Add a pragmatic SwiftLint configuration
Baseline .swiftlint.yml for local use (not wired into CI yet):

- file_length: warning at 600, error at 4000 — above the current
  maximum (BLEService.swift, ~3,500 lines), so the codebase passes
  as-is and only future growth trips the error.
- function_body_length: warning 100, error 200.
- Disables the high-noise style rules (line_length, identifier_name,
  cyclomatic_complexity, force_cast/force_try for tests, etc.) so the
  remaining defaults stay actionable; SwiftLint is not installed in
  this environment, so the disabled list is a best-effort starting
  point to be tuned on first real run.
- Excludes vendored Arti and SwiftPM .build output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:32:19 +02:00
jackandClaude Fable 5 5be8b5b37f Track CLAUDE.md and update it to match the current architecture
The previous (untracked) CLAUDE.md described ChatViewModel as a ~170KB
class split into +Nostr/+PrivateChat/+Tor extension files. Reality:
ChatViewModel.swift is ~1,600 lines and delegates to ~25 coordinator/
pipeline types in bitchat/ViewModels/; the Extensions/ files are thin
delegation shims. Document the actual structure: AppRuntime as the
composition root, ConversationStore as the single-writer message
store, the BLE handler/policy collaborators around BLEService, and
BinaryProtocol's move into the BitFoundation local package.

Also start tracking CLAUDE.md (removing its .gitignore entry) so the
guidance stays versioned alongside the code it describes; build/test
command sections are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:59 +02:00
jackandClaude Fable 5 7200b3de2a Add NIP-44 style padding support to Nostr DM encryption
The bespoke "v2" DM envelope (XChaCha20-Poly1305 over the raw UTF-8
rumor JSON) leaks exact plaintext length to relays. Add NIP-44 v2
style payload framing: a 2-byte big-endian length prefix followed by
zero padding to NIP-44's calc_padded_len buckets (32-byte minimum,
power-of-two-derived chunks), carried in a new "v3:" envelope that
reuses the existing key derivation and AEAD.

Compatibility: deployed clients hard-reject any envelope that does not
start with "v2:", and the v2 payload is raw JSON, so padded payloads
cannot be introduced inside v2 either. This lands phase 1 of a
two-phase rollout: decrypt accepts both v2 (unpadded) and v3 (padded),
while encrypt keeps emitting v2, gated by
NostrProtocol.sendPaddedEnvelope (defaults to false with the rollout
plan documented inline). Flip the flag once v3-capable clients are
widely deployed.

Unpad validates that the claimed length's bucket exactly matches the
authenticated payload size, so a tampered or malformed length prefix
fails closed. Tests cover pad/unpad round-trips, the NIP-44 bucket
vectors, v3 round-trip, legacy v2 decrypt, default-envelope pinning,
and tampered length prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:30:00 +02:00
jackandClaude Fable 5 3f6dc7dd36 Speed up hex encode/decode with lookup tables
Data.hexEncodedString() built each byte via String(format: "%02x", _),
paying Foundation format-string parsing per byte on the hot BLE receive
path (PeerID(hexData:) is called several times per received packet).
Replace it with an ASCII lookup table writing into a preallocated
buffer; output is byte-for-byte identical (lowercase hex).

Data(hexString:) similarly allocated a two-character substring and went
through integer radix parsing per byte; replace with direct nibble
lookups over the UTF-8 bytes. Semantics are unchanged except that
sign-prefixed pairs like "+f", which the old radix parser incorrectly
accepted, are now rejected.

Add round-trip, known-vector, and invalid-input tests to
BitFoundationTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:26:38 +02:00
17 changed files with 625 additions and 908 deletions
-2
View File
@@ -6,7 +6,6 @@
plans/
## AI
CLAUDE.md
AGENTS.md
.claude/
@@ -80,4 +79,3 @@ build.log
# Local configs
Local.xcconfig
*.profraw
+51
View File
@@ -0,0 +1,51 @@
# SwiftLint configuration for BitChat.
#
# Intentionally pragmatic: rules that would flood the existing codebase are
# disabled so the lint signal stays actionable; re-enable them individually as
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
# from the repo root.
included:
- bitchat
- bitchatTests
- localPackages/BitFoundation/Sources
- localPackages/BitFoundation/Tests
- localPackages/BitLogger/Sources
excluded:
- .build
- localPackages/Arti
- localPackages/BitFoundation/.build
- localPackages/BitLogger/.build
disabled_rules:
# Style/volume rules that currently produce noise across the codebase.
- line_length
- identifier_name
- type_name
- type_body_length
- cyclomatic_complexity
- function_parameter_count
- large_tuple
- nesting
- todo
- trailing_comma
- opening_brace
- statement_position
- for_where
- redundant_string_enum_value
- non_optional_string_data_conversion
# Common in tests (force casts/tries on fixtures).
- force_cast
- force_try
file_length:
warning: 600
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
# today's maximum so the codebase passes as-is and only future growth of the
# largest files trips it.
error: 4000
function_body_length:
warning: 100
error: 200
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build macOS (no signing)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
# Build iOS for simulator
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
# Run all tests (iOS simulator)
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
# Run tests via Swift Package Manager (used in CI)
swift build && swift test --parallel
# Clean build
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
# Quick dev build and run (macOS only, requires `just`)
just run
```
### Running Specific Tests
```bash
# Run a single test class
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
# Run a single test method
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
```
## Architecture Overview
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
### Local Packages (`localPackages/`)
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
- **BitLogger**: `SecureLogger` logging.
- **Arti**: Tor integration (exposes the `Tor` product).
### Transport Layer
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
### Encryption Layer
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
- `NoiseEncryptionService`: Main encryption/decryption API
- `NoiseSessionManager`: Thread-safe per-peer session management
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
### Protocol Layer
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
### Application Layer
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
- `ChatNostrCoordinator``GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
## Test Infrastructure
Tests use an **in-memory networking harness** for deterministic, race-free testing:
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
- `MockBLEService.resetTestBus()` - Clear state in setUp()
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
- **Test categories**:
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
- Unit tests: Individual component tests
## Key Patterns
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
## Device Setup
To run on physical devices:
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
2. Add your Developer Team ID to `Local.xcconfig`
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
+6 -28
View File
@@ -141,44 +141,22 @@ enum TrustLevel: String, Codable {
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> = []
// 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 (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
}
}
//
+43 -104
View File
@@ -145,29 +145,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside
// `cache` so they persist across app restarts; see IdentityCache.
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
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 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`.
// 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.)
private var pendingSave = false
// Encryption key
@@ -227,22 +216,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
deinit {
// 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)
}
forceSave()
}
// MARK: - Secure Loading/Saving
@@ -267,27 +241,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
/// 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.
/// 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.
private func saveIdentityCache() {
pendingSave = true
// On the barrier context already: snapshot is trivially consistent.
persist(snapshot: cache)
pendingSave = false
performSave()
}
/// 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) {
/// Writes the cache to the keychain. Must run on `queue` with exclusive
/// (barrier) access.
private func performSave() {
guard pendingSave else { return }
pendingSave = false
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
@@ -296,7 +264,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
do {
let data = try JSONEncoder().encode(snapshot)
let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved {
@@ -307,26 +275,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// 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`).
// 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.
func forceSave() {
queue.sync(flags: .barrier) {
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
performSave()
}
// MARK: - Social Identity Management
@@ -340,33 +296,15 @@ 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.sync(flags: .barrier) {
queue.async(flags: .barrier) {
let now = Date()
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
}
if var existing = self.cryptographicIdentities[fingerprint] {
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
@@ -376,7 +314,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cache.cryptographicIdentities[fingerprint] = existing
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
@@ -387,7 +325,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cache.cryptographicIdentities[fingerprint] = updated
self.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
@@ -399,7 +337,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: now,
lastHandshake: now
)
self.cache.cryptographicIdentities[fingerprint] = entry
self.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
@@ -431,12 +369,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.isShort else { return [] }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity
@@ -472,7 +410,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity
@@ -510,7 +448,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked
if isBlocked {
@@ -544,7 +482,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased()
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
if isBlocked {
self.cache.blockedNostrPubkeys.insert(key)
} else {
@@ -561,7 +499,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
@@ -571,7 +509,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction
@@ -587,10 +525,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security)
queue.sync(flags: .barrier) {
queue.async(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)
@@ -598,7 +537,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
}
}
@@ -608,7 +547,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) {
queue.async(flags: .barrier) {
if verified {
self.cache.verifiedFingerprints.insert(fingerprint)
} else {
+117 -25
View File
@@ -328,52 +328,85 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
// MARK: - Encryption (NIP-44 v2/v3)
/// Whether outgoing DMs use the padded "v3:" envelope.
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
padded: Bool = NostrProtocol.sendPaddedEnvelope
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
let payload = padded ? try NIP44Padding.pad(pt) : pt
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// version prefix + base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
}
private static func decrypt(
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
// envelope (see `sendPaddedEnvelope` for the rollout plan).
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
@@ -399,18 +432,23 @@ struct NostrProtocol {
}
// If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
payload = pt
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
payload = try attemptDecrypt(using: senderPubkeyData)
}
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -641,6 +679,60 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol {
+9 -41
View File
@@ -14,14 +14,8 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// 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?
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (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,15 +23,13 @@ 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
@@ -107,16 +99,7 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
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 existingNoisePublicKey = env.existingNoisePublicKey(peerID)
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
@@ -130,18 +113,13 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey,
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
announcedSigningPublicKey: announcement.signingPublicKey
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
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
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
@@ -161,22 +139,12 @@ final class BLEAnnounceHandler {
return
}
// 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(
let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now
) else {
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
)
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case signingKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -73,25 +72,12 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data,
existingSigningPublicKey: Data?,
announcedSigningPublicKey: Data
announcedNoisePublicKey: 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)
}
+2 -18
View File
@@ -150,14 +150,6 @@ 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,
@@ -165,15 +157,8 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate? {
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
if let pinnedSigningKey = existing?.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
return nil
}
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
@@ -185,8 +170,7 @@ struct BLEPeerRegistry {
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
// Never drop an already-pinned signing key.
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
+4 -19
View File
@@ -3028,21 +3028,9 @@ extension BLEService {
},
messageTTL: messageTTL,
now: { Date() },
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.
existingNoisePublicKey: { [weak self] peerID in
guard let self = self else { return nil }
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
@@ -3055,17 +3043,14 @@ extension BLEService {
},
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly.
guard let self = self else {
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}
return self.peerRegistry.upsertVerifiedAnnounce(
self?.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly.
+153
View File
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -6,12 +6,9 @@ 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 upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true
@@ -38,11 +35,7 @@ struct BLEAnnounceHandlerTests {
localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -375,168 +368,6 @@ 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)
@@ -560,251 +391,6 @@ 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) },
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) {
#expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty)
@@ -123,9 +123,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: false,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
)
#expect(decision == .reject(.missingSignature))
@@ -138,9 +136,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
)
#expect(decision == .reject(.invalidSignature))
@@ -152,9 +148,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
)
#expect(decision == .reject(.keyMismatch))
@@ -168,51 +162,13 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
announcedNoisePublicKey: noiseKey
)
#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() throws {
func upsertVerifiedAnnounceTracksTransitions() {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100)
let firstResult = registry.upsertVerifiedAnnounce(
let first = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: Data([1, 2, 3]),
@@ -19,7 +19,6 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen
)
let first = try #require(firstResult)
#expect(first.isNewPeer)
#expect(!first.wasDisconnected)
@@ -28,7 +27,7 @@ struct BLEPeerRegistryTests {
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID)
let reconnectResult = registry.upsertVerifiedAnnounce(
let reconnect = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]),
@@ -36,7 +35,6 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
let reconnect = try #require(reconnectResult)
#expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected)
@@ -44,85 +42,6 @@ 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,100 +79,6 @@ 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)
@@ -8,12 +8,44 @@
import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String {
if self.isEmpty {
if isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
var output = [UInt8](repeating: 0, count: count * 2)
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
}
/// Initialize Data from a hex string.
@@ -28,28 +60,28 @@ public extension Data {
hex = String(hex.dropFirst(2))
}
let ascii = Array(hex.utf8)
// Reject odd-length strings
guard hex.count % 2 == 0 else {
guard ascii.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
// Accept empty strings
guard !ascii.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
var data = Data(capacity: ascii.count / 2)
var index = 0
while index < ascii.count {
guard let high = hexNibble(ascii[index]),
let low = hexNibble(ascii[index + 1]) else {
return nil
}
data.append(byte)
index = nextIndex
data.append((high << 4) | low)
index += 2
}
self = data
@@ -0,0 +1,78 @@
//
// DataHexTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct DataHexTests {
// MARK: - Encoding
@Test func encode_knownVectors() {
#expect(Data().hexEncodedString() == "")
#expect(Data([0x00]).hexEncodedString() == "00")
#expect(Data([0x0f]).hexEncodedString() == "0f")
#expect(Data([0xf0]).hexEncodedString() == "f0")
#expect(Data([0xff]).hexEncodedString() == "ff")
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
}
@Test func encode_allByteValues_matchesFormatReference() {
let all = Data((0...255).map { UInt8($0) })
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
#expect(all.hexEncodedString() == reference)
}
@Test func encode_worksOnDataSlices() {
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
let slice = data.dropFirst().dropLast()
#expect(slice.hexEncodedString() == "deadbeef")
}
// MARK: - Decoding
@Test func decode_knownVectors() {
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "00") == Data([0x00]))
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
}
@Test func decode_handlesPrefixAndWhitespace() {
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "") == Data())
#expect(Data(hexString: "0x") == Data())
}
@Test func decode_rejectsInvalidInput() {
#expect(Data(hexString: "abc") == nil) // odd length
#expect(Data(hexString: "zz") == nil) // non-hex characters
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
#expect(Data(hexString: "-0") == nil)
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
#expect(Data(hexString: "de ad") == nil) // interior whitespace
}
// MARK: - Round trip
@Test func roundTrip_randomLengths() {
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let hex = data.hexEncodedString()
#expect(hex.count == length * 2)
#expect(Data(hexString: hex) == data)
#expect(Data(hexString: hex.uppercased()) == data)
}
}
}