mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41486fd1c9 | ||
|
|
d8527b98fd | ||
|
|
5be8b5b37f | ||
|
|
7200b3de2a | ||
|
|
3f6dc7dd36 |
@@ -6,7 +6,6 @@
|
|||||||
plans/
|
plans/
|
||||||
|
|
||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
@@ -80,4 +79,3 @@ build.log
|
|||||||
|
|
||||||
# Local configs
|
# Local configs
|
||||||
Local.xcconfig
|
Local.xcconfig
|
||||||
*.profraw
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -141,44 +141,22 @@ enum TrustLevel: String, Codable {
|
|||||||
struct IdentityCache: Codable {
|
struct IdentityCache: Codable {
|
||||||
// Fingerprint -> Social mapping
|
// Fingerprint -> Social mapping
|
||||||
var socialIdentities: [String: SocialIdentity] = [:]
|
var socialIdentities: [String: SocialIdentity] = [:]
|
||||||
|
|
||||||
// Nickname -> [Fingerprints] reverse index
|
// Nickname -> [Fingerprints] reverse index
|
||||||
// Multiple fingerprints can claim same nickname
|
// Multiple fingerprints can claim same nickname
|
||||||
var nicknameIndex: [String: Set<String>] = [:]
|
var nicknameIndex: [String: Set<String>] = [:]
|
||||||
|
|
||||||
// Verified fingerprints (cryptographic proof)
|
// Verified fingerprints (cryptographic proof)
|
||||||
var verifiedFingerprints: Set<String> = []
|
var verifiedFingerprints: Set<String> = []
|
||||||
|
|
||||||
// Last interaction timestamps (privacy: optional)
|
// Last interaction timestamps (privacy: optional)
|
||||||
var lastInteractions: [String: Date] = [:]
|
var lastInteractions: [String: Date] = [:]
|
||||||
|
|
||||||
// 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,29 +145,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
// Cryptographic identities (including pinned signing keys) live inside
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
// `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
|
||||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||||
// returns the write is already complete and NOTHING is left scheduled on
|
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
// no run loop, so saves never actually fired.)
|
||||||
// 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
|
||||||
@@ -227,22 +216,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
forceSave()
|
||||||
// (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
|
||||||
@@ -267,27 +241,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||||
/// while serialized. The encode + keychain write are done here (already on
|
/// and persists it on the same serialized context — no timer, nothing left
|
||||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
/// scheduled to keep the process alive.
|
||||||
/// scheduled and nothing is left to keep the process alive.
|
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
// On the barrier context already: snapshot is trivially consistent.
|
performSave()
|
||||||
persist(snapshot: cache)
|
|
||||||
pendingSave = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||||
///
|
/// (barrier) access.
|
||||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
private func performSave() {
|
||||||
/// under `queue` and then encode without holding it. Reading `cache`
|
guard pendingSave else { return }
|
||||||
/// concurrently with a barrier writer would be a data race on the
|
pendingSave = false
|
||||||
/// 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 {
|
||||||
@@ -296,7 +264,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(snapshot)
|
let data = try JSONEncoder().encode(cache)
|
||||||
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 {
|
||||||
@@ -307,26 +275,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||||
// API already persists inline inside its own barrier via
|
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||||
// already up to date and this is normally a no-op; it exists as a
|
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||||
//
|
|
||||||
// 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() {
|
||||||
queue.sync(flags: .barrier) {
|
performSave()
|
||||||
guard pendingSave else { return }
|
|
||||||
pendingSave = false
|
|
||||||
persist(snapshot: cache)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
@@ -340,33 +296,15 @@ 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.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
if var existing = self.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(
|
||||||
@@ -376,7 +314,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
self.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
|
||||||
@@ -387,7 +325,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = updated
|
self.cryptographicIdentities[fingerprint] = updated
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -399,7 +337,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: now,
|
firstSeen: now,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally persist claimed nickname into social identity
|
// Optionally persist claimed nickname into social identity
|
||||||
@@ -431,12 +369,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 cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(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
|
||||||
|
|
||||||
@@ -472,7 +410,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(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
|
||||||
@@ -510,7 +448,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.sync(flags: .barrier) {
|
queue.async(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 {
|
||||||
@@ -544,7 +482,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.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
self.cache.blockedNostrPubkeys.insert(key)
|
self.cache.blockedNostrPubkeys.insert(key)
|
||||||
} else {
|
} else {
|
||||||
@@ -561,7 +499,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.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
sessionStart: Date(),
|
sessionStart: Date(),
|
||||||
@@ -571,7 +509,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
// If handshake completed, update last interaction
|
// If handshake completed, update last interaction
|
||||||
@@ -587,10 +525,11 @@ 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.sync(flags: .barrier) {
|
queue.async(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)
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||||
@@ -598,7 +537,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +547,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.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -328,52 +328,85 @@ struct NostrProtocol {
|
|||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 v2)
|
// MARK: - Encryption (NIP-44 v2/v3)
|
||||||
|
|
||||||
private static func encrypt(
|
/// 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,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
padded: Bool = NostrProtocol.sendPaddedEnvelope
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
|
||||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
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)
|
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||||
|
|
||||||
// 24-byte random nonce for XChaCha20-Poly1305
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
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 pt = Data(plaintext.utf8)
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
let payload = padded ? try NIP44Padding.pad(pt) : pt
|
||||||
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
|
||||||
// v2: base64url(nonce24 || ciphertext || tag)
|
|
||||||
|
// version prefix + base64url(nonce24 || ciphertext || tag)
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
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,
|
ciphertext: String,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Expect NIP-44 v2 format
|
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
// 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))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
@@ -399,18 +432,23 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
|
let payload: Data
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let even = Data([0x02]) + senderPubkeyData
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
if let pt = try? attemptDecrypt(using: even) {
|
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 {
|
} else {
|
||||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
payload = try attemptDecrypt(using: senderPubkeyData)
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
private static func deriveSharedSecret(
|
||||||
@@ -641,6 +679,60 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
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)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
|
|||||||
@@ -14,14 +14,8 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise and signing public keys already recorded for the peer, if any
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
/// (single registry read so both come from one consistent snapshot).
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
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).
|
||||||
@@ -29,15 +23,13 @@ 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
|
||||||
@@ -107,16 +99,7 @@ final class BLEAnnounceHandler {
|
|||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
let existingNoisePublicKey = env.existingNoisePublicKey(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 {
|
||||||
@@ -130,18 +113,13 @@ final class BLEAnnounceHandler {
|
|||||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
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)
|
||||||
}
|
}
|
||||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
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
|
||||||
@@ -161,22 +139,12 @@ final class BLEAnnounceHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The registry re-checks the signing-key pin inside the barrier.
|
let update = env.upsertVerifiedAnnounce(
|
||||||
// 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,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
case signingKeyMismatch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -73,25 +72,12 @@ 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,14 +150,6 @@ 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,
|
||||||
@@ -165,15 +157,8 @@ 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,
|
||||||
@@ -185,8 +170,7 @@ struct BLEPeerRegistry {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
// Never drop an already-pinned signing key.
|
signingPublicKey: signingPublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3028,21 +3028,9 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
messageTTL: messageTTL,
|
messageTTL: messageTTL,
|
||||||
now: { Date() },
|
now: { Date() },
|
||||||
existingPeerKeys: { [weak self] peerID in
|
existingNoisePublicKey: { [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.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||||
.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
|
||||||
@@ -3055,17 +3043,14 @@ 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.
|
||||||
guard let self = self else {
|
self?.peerRegistry.upsertVerifiedAnnounce(
|
||||||
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.
|
||||||
|
|||||||
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#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
|
// MARK: - Helpers
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ 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? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
var dedupSeenIDs: Set<String> = []
|
var dedupSeenIDs: Set<String> = []
|
||||||
var shouldEmitReconnectLogResult = true
|
var shouldEmitReconnectLogResult = true
|
||||||
|
|
||||||
@@ -38,11 +35,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
localPeerID: { localPeerID },
|
localPeerID: { localPeerID },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||||
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
|
||||||
@@ -375,168 +368,6 @@ 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)
|
||||||
@@ -560,251 +391,6 @@ 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,9 +123,7 @@ 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))
|
||||||
@@ -138,9 +136,7 @@ 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))
|
||||||
@@ -152,9 +148,7 @@ 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))
|
||||||
@@ -168,51 +162,13 @@ 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() throws {
|
func upsertVerifiedAnnounceTracksTransitions() {
|
||||||
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 firstResult = registry.upsertVerifiedAnnounce(
|
let first = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice",
|
nickname: "alice",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -19,7 +19,6 @@ 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)
|
||||||
@@ -28,7 +27,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 reconnectResult = registry.upsertVerifiedAnnounce(
|
let reconnect = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice-renamed",
|
nickname: "alice-renamed",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -36,7 +35,6 @@ 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)
|
||||||
@@ -44,85 +42,6 @@ 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,100 +79,6 @@ 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)
|
||||||
|
|||||||
@@ -8,12 +8,44 @@
|
|||||||
|
|
||||||
import struct Foundation.Data
|
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 {
|
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 {
|
func hexEncodedString() -> String {
|
||||||
if self.isEmpty {
|
if isEmpty {
|
||||||
return ""
|
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.
|
/// Initialize Data from a hex string.
|
||||||
@@ -28,28 +60,28 @@ public extension Data {
|
|||||||
hex = String(hex.dropFirst(2))
|
hex = String(hex.dropFirst(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ascii = Array(hex.utf8)
|
||||||
|
|
||||||
// Reject odd-length strings
|
// Reject odd-length strings
|
||||||
guard hex.count % 2 == 0 else {
|
guard ascii.count % 2 == 0 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject empty strings
|
// Accept empty strings
|
||||||
guard !hex.isEmpty else {
|
guard !ascii.isEmpty else {
|
||||||
self = Data()
|
self = Data()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let len = hex.count / 2
|
var data = Data(capacity: ascii.count / 2)
|
||||||
var data = Data(capacity: len)
|
var index = 0
|
||||||
var index = hex.startIndex
|
while index < ascii.count {
|
||||||
|
guard let high = hexNibble(ascii[index]),
|
||||||
for _ in 0..<len {
|
let low = hexNibble(ascii[index + 1]) else {
|
||||||
let nextIndex = hex.index(index, offsetBy: 2)
|
|
||||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data.append(byte)
|
data.append((high << 4) | low)
|
||||||
index = nextIndex
|
index += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
self = data
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user