Merge c654590f9ab090c4fd517b8876dccf33b3b4f0c6 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Martti Malmi 2026-08-07 00:42:11 +00:00 committed by GitHub
commit b7dadebfab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 12966 additions and 267 deletions

View File

@ -18,6 +18,21 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
submodules: true
- name: Build NdrFfi from pinned source
run: |
rust_version="$(tr -d '[:space:]' < localPackages/NdrFfi/RUST_TOOLCHAIN)"
rustup toolchain install "$rust_version" --profile minimal
rustup default "$rust_version"
rustup target add \
aarch64-apple-darwin \
x86_64-apple-darwin \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
./localPackages/NdrFfi/build-apple.sh
- name: Install Periphery
# homebrew-core formula; the peripheryapp tap lags years behind.

View File

@ -37,6 +37,7 @@ jobs:
ref: ${{ github.event.inputs.ref || github.ref }}
# Full history so the commit the tag names can be recorded exactly.
fetch-depth: 0
submodules: recursive
- name: Build manifest
id: build
@ -46,13 +47,34 @@ jobs:
commit="$(git rev-parse HEAD)"
tree="$(git rev-parse HEAD^{tree})"
# Hash every tracked file, in a stable order, with NUL separation so
# paths containing spaces or newlines cannot shift the columns.
git ls-files -z \
# Hash every regular tracked file in a stable order. Gitlinks name a
# directory rather than a hashable file, so record and verify their
# exact commits separately.
git ls-files -s -z \
| while IFS= read -r -d '' entry; do
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
mode="${metadata%% *}"
if [ "$mode" != "160000" ]; then
printf '%s\0' "$path"
fi
done \
| sort -z \
| xargs -0 sha256sum \
> files.sha256
: > gitlinks.tsv
git ls-files -s -z \
| while IFS= read -r -d '' entry; do
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
read -r mode object stage <<< "$metadata"
if [ "$mode" = "160000" ]; then
printf '%s\t%s\n' "$path" "$object"
fi
done \
| sort > gitlinks.tsv
{
echo "# bitchat source manifest"
echo "#"
@ -60,9 +82,13 @@ jobs:
echo "# commit: ${commit}"
echo "# tree: ${tree}"
echo "# files: $(wc -l < files.sha256 | tr -d ' ')"
while IFS=$'\t' read -r path object; do
[ -n "$path" ] && echo "# gitlink: ${path} ${object}"
done < gitlinks.tsv
echo "#"
echo "# Verify a checkout of this ref with:"
echo "# shasum -a 256 -c files.sha256"
echo "# git submodule status --recursive"
echo "# Hash checking alone ignores files this manifest does not list, and"
echo "# the Xcode project compiles any source file present in the tree. So"
echo "# also confirm nothing extra is present:"
@ -85,6 +111,11 @@ jobs:
# is worse than none, so fail loudly rather than publishing it.
grep -v '^#' SOURCE-MANIFEST.txt > check.sha256
sha256sum -c check.sha256 > /dev/null
while IFS=$'\t' read -r path expected; do
[ -z "$path" ] && continue
test "$(git rev-parse ":${path}")" = "$expected"
test "$(git -C "$path" rev-parse HEAD)" = "$expected"
done < gitlinks.tsv
echo "manifest validates against this checkout"
- name: Attest the manifest

View File

@ -7,9 +7,56 @@ on:
pull_request:
jobs:
ndr-ffi-apple:
name: Build NdrFfi from pinned source
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code and pinned Rust source
uses: actions/checkout@v5
with:
submodules: true
- name: Install pinned Rust toolchain and Apple targets
run: |
rust_version="$(tr -d '[:space:]' < localPackages/NdrFfi/RUST_TOOLCHAIN)"
rustup toolchain install "$rust_version" --profile minimal
rustup default "$rust_version"
rustup target add \
aarch64-apple-darwin \
x86_64-apple-darwin \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
- name: Cache Rust build inputs
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
.cache/ndr-ffi/apple/target
key: ndr-ffi-apple-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('localPackages/NdrFfi/RUST_TOOLCHAIN', 'localPackages/NdrFfi/SOURCE_REVISION', 'localPackages/NdrFfi/build-apple.sh', 'vendor/nostr-double-ratchet/rust/Cargo.lock') }}
- name: Build Apple FFI from source
run: ./localPackages/NdrFfi/build-apple.sh
- name: Verify generated Swift bindings
run: git diff --exit-code -- localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift
- name: Upload generated XCFramework
uses: actions/upload-artifact@v4
with:
name: ndr-ffi-xcframework
path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework
if-no-files-found: error
retention-days: 1
test:
name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest
needs: ndr-ffi-apple
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
# The long steps carry tighter individual bounds (5-minute test watchdog,
@ -27,11 +74,20 @@ jobs:
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: NdrFfi
path: localPackages/NdrFfi
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download source-built NdrFfi
if: matrix.name == 'app' || matrix.name == 'NdrFfi'
uses: actions/download-artifact@v5
with:
name: ndr-ffi-xcframework
path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
# the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
@ -138,12 +194,19 @@ jobs:
ios-build:
name: Build Release apps (universal)
runs-on: macos-latest
needs: ndr-ffi-apple
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download source-built NdrFfi
uses: actions/download-artifact@v5
with:
name: ndr-ffi-xcframework
path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework
- name: Check clean recipe safety
run: bash scripts/check-just-clean-safety.sh
@ -184,12 +247,19 @@ jobs:
ios-tests:
name: Run iOS simulator tests
runs-on: macos-latest
needs: ndr-ffi-apple
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download source-built NdrFfi
uses: actions/download-artifact@v5
with:
name: ndr-ffi-xcframework
path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework
# Some runner images list only placeholder destinations (rows carrying
# "error:" or "unavailable") or ship the selected Xcode without a
# matching iOS simulator runtime, so this walks three paths in order:

3
.gitignore vendored
View File

@ -81,3 +81,6 @@ build.log
# Local configs
Local.xcconfig
*.profraw
# Built from the pinned nostr-double-ratchet submodule; never commit native crypto.
localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "vendor/nostr-double-ratchet"]
path = vendor/nostr-double-ratchet
url = https://github.com/irislib/nostr-double-ratchet.git
shallow = true

View File

@ -14,6 +14,7 @@ default:
@echo " just build Build the macOS app without signing"
@echo " just test Run the SwiftPM test suite"
@echo " just test-ios Run tests on the iPhone 17 simulator"
@echo " just ndr-ffi Build NdrFfi from the pinned Rust source"
@echo " just clean Remove repo-local build artifacts only"
@echo " just nuke Also remove nested package build caches"
@echo " just check Validate the development environment"
@ -30,7 +31,11 @@ check: check-clean-safety
@xcodebuild -version
@echo "✅ Development environment ready (a signing identity is not required for just build)"
build: check
ndr-ffi:
@command -v cargo >/dev/null 2>&1 || (echo "❌ cargo not found. Install Rust with rustup." && exit 1)
@./localPackages/NdrFfi/build-apple.sh
build: check ndr-ffi
@echo "Building BitChat for macOS..."
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
@ -40,10 +45,10 @@ run: build
# Backward-compatible alias for the old quick-run recipe.
dev-run: run
test:
test: ndr-ffi
@swift test
test-ios: check
test-ios: check ndr-ffi
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
# Artifact-only cleanup. In particular, this recipe never invokes Git and

View File

@ -19,6 +19,7 @@ let package = Package(
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
.package(path: "localPackages/NdrFfi"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
],
targets: [
@ -28,7 +29,8 @@ let package = Package(
.product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Arti")
.product(name: "Tor", package: "Arti"),
.product(name: "NdrFfi", package: "NdrFfi")
],
path: "bitchat",
exclude: [
@ -48,7 +50,8 @@ let package = Package(
name: "bitchatTests",
dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "NdrFfi", package: "NdrFfi")
],
path: "bitchatTests",
exclude: [

View File

@ -105,6 +105,26 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
## Setup
Initialize the pinned Rust source and build its generated Apple framework once
before opening the project or running `swift test` directly:
```bash
git submodule update --init --checkout vendor/nostr-double-ratchet
rustup toolchain install "$(cat localPackages/NdrFfi/RUST_TOOLCHAIN)" --profile minimal
rustup target add \
aarch64-apple-darwin \
x86_64-apple-darwin \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
./localPackages/NdrFfi/build-apple.sh
```
The generated XCFramework is ignored; every checkout builds it from the exact
`nostr-double-ratchet` source revision and locked Rust dependency graph recorded
in the repository. `just build`, `just run`, and `just test` perform the build
step automatically.
### Option 1: Using Xcode
```bash

View File

@ -229,6 +229,7 @@
A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
NDRF0002000000000000000000 /* NdrFfi */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@ -314,6 +315,7 @@
A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
NDRF0003000000000000000000 /* NdrFfi */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@ -357,6 +359,7 @@
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@ -927,6 +930,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Arti;
};
NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/NdrFfi;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
@ -976,6 +983,14 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K;
};
NDRF0002000000000000000000 /* NdrFfi */ = {
isa = XCSwiftPackageProductDependency;
productName = NdrFfi;
};
NDRF0003000000000000000000 /* NdrFfi */ = {
isa = XCSwiftPackageProductDependency;
productName = NdrFfi;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 475D96681D0EA0AE57A4E06E /* Project object */;

View File

@ -2,11 +2,22 @@ import BitFoundation
import Foundation
import CryptoKit
enum NostrIdentityBridgeError: Error, Equatable {
case identityReadUnavailable
case invalidStoredIdentity
case identityPersistenceFailed
}
/// Bridge between Noise and Nostr identities
final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// Multiple production owners construct their own bridge. Creation and
// panic deletion of the shared current-identity keychain item must still
// be one process-wide lifecycle, or concurrent bridges can return
// different keys or an in-flight save can resurrect a wiped identity.
private static let identityLifecycleLock = NSLock()
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering
@ -21,10 +32,26 @@ final class NostrIdentityBridge {
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
Self.identityLifecycleLock.lock()
defer { Self.identityLifecycleLock.unlock() }
switch keychain.loadWithResult(
key: currentIdentityKey,
service: keychainService
) {
case .success(let existingData):
guard let identity = try? JSONDecoder().decode(
NostrIdentity.self,
from: existingData
) else {
throw NostrIdentityBridgeError.invalidStoredIdentity
}
return identity
case .itemNotFound:
break
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
throw NostrIdentityBridgeError.identityReadUnavailable
}
// Generate new Nostr identity
@ -33,8 +60,21 @@ final class NostrIdentityBridge {
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
guard case .success(let persistedData) = keychain.loadWithResult(
key: currentIdentityKey,
service: keychainService
),
persistedData == data,
let persistedIdentity = try? JSONDecoder().decode(
NostrIdentity.self,
from: persistedData
),
persistedIdentity.publicKeyHex == nostrIdentity.publicKeyHex
else {
throw NostrIdentityBridgeError.identityPersistenceFailed
}
return persistedIdentity
}
/// Get Nostr public key associated with a Noise public key
@ -49,6 +89,9 @@ final class NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
Self.identityLifecycleLock.lock()
defer { Self.identityLifecycleLock.unlock() }
// Must go through the injected keychain, not raw SecItem calls:
// under test that keychain is in-memory, and a direct delete here
// would wipe the developer's real Nostr identity on every test run.

View File

@ -868,6 +868,14 @@ struct NostrEvent: Codable {
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
/// Validate the deterministic Nostr event ID without requiring a
/// signature. Ratchet-decrypted kind-14 rumors are intentionally unsigned,
/// but their IDs must still commit to their complete contents.
func hasValidEventID() -> Bool {
guard let (expectedId, _) = try? calculateEventId() else { return false }
return expectedId == id
}
private func calculateEventId() throws -> (String, Data) {
let serialized = [

View File

@ -71,6 +71,7 @@ struct NostrRelayManagerDependencies {
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var verifyEventSignature: @Sendable (NostrEvent) -> Bool
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
/// Uniform random value in [0, 1) used to jitter reconnect backoff.
@ -112,6 +113,7 @@ private extension NostrRelayManagerDependencies {
}
},
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
verifyEventSignature: { $0.isValidSignature() },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
@ -198,7 +200,10 @@ final class NostrRelayManager: ObservableObject {
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
// Relay URL -> subscription ID -> logical request generation currently
// installed on that socket. A same-ID REQ with a newer generation
// atomically replaces its filter under NIP-01.
private var subscriptions: [String: [String: UInt64]] = [:]
// Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by
// insertion order evicted) and an age sweep on connect attempts. Dicts are
// unordered, so each entry carries an insertion sequence and queue time.
@ -210,6 +215,12 @@ final class NostrRelayManager: ObservableObject {
private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ)
private var pendingSubscriptionSequence: UInt64 = 0
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// An EVENT is admitted before off-main signature verification and
// delivered later. The generation binds those two phases to the same
// logical subscription so unsubscribe/re-subscribe cannot retarget stale
// work to a replacement handler or poison its dedup state.
private var subscriptionGenerations: [String: UInt64] = [:]
private var nextSubscriptionGeneration: UInt64 = 0
private struct InboundEventKey: Hashable {
let subscriptionID: String
let eventID: String
@ -221,9 +232,6 @@ final class NostrRelayManager: ObservableObject {
private var duplicateInboundEventDropCount = 0
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
private var inboundEventLogCount = 0
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var torReadyWaitAttempts = 0
@ -391,6 +399,7 @@ final class NostrRelayManager: ObservableObject {
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let verifyEventSignature = dependencies.verifyEventSignature
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
@ -398,21 +407,24 @@ final class NostrRelayManager: ObservableObject {
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
guard let generation = await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
) else { continue }
guard verifyEventSignature(event) else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
await self.deliverVerifiedInboundEvent(
subscriptionID: subId,
subscriptionGeneration: generation,
event: event,
from: relayUrl
)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
@ -488,8 +500,8 @@ final class NostrRelayManager: ObservableObject {
subscriptions.removeAll()
pendingSubscriptions.removeAll()
messageHandlers.removeAll()
subscriptionGenerations.removeAll()
subscriptionRequestState.removeAll()
subscribeCoalesce.removeAll()
eoseTrackers.removeAll()
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
@ -735,23 +747,19 @@ final class NostrRelayManager: ObservableObject {
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
/// Subscribe to events matching a filter. If `relayUrls` provided, targets
/// only those relays. Returns true only after the replayable subscription
/// intent has been registered, even when every target is still offline.
@discardableResult
func subscribe(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
) -> Bool {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
return
}
subscribeCoalesce[id] = now
messageHandlers[id] = handler
guard dependencies.activationAllowed() else { return false }
let req = NostrRequest.subscribe(id: id, filters: [filter])
@ -759,7 +767,7 @@ final class NostrRelayManager: ObservableObject {
let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return
return false
}
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
@ -767,10 +775,37 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? defaultRelays
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
guard !urls.isEmpty else {
onEOSE?()
return false
}
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
let previousRequestState = subscriptionRequestState[id]
if subscriptionGenerations[id] == nil
|| previousRequestState != requestState
{
nextSubscriptionGeneration &+= 1
subscriptionGenerations[id] = nextSubscriptionGeneration
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(
forKey: id
)
}
if let previousRequestState {
let removedRelayURLs = previousRequestState.relayURLs
.subtracting(requestState.relayURLs)
closeSubscription(
id: id,
on: removedRelayURLs
)
}
messageHandlers[id] = handler
if previousRequestState == requestState,
subscriptionStateExists(id: id, requestState: requestState)
{
return true
}
subscriptionRequestState[id] = requestState
// Always queue subscriptions; sending happens when a relay reports connected
@ -801,8 +836,13 @@ final class NostrRelayManager: ObservableObject {
flushPendingSubscriptions(for: url)
}
}
// `subscriptionRequestState` is the canonical replay intent.
// Pending REQs are bounded/expiring accelerators and may be
// evicted; reconnect rehydrates them from this exact state.
return subscriptionRequestState[id] == requestState
} catch {
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
return false
}
}
@ -870,30 +910,45 @@ final class NostrRelayManager: ObservableObject {
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
var relayURLs = subscriptionRequestState[id]?.relayURLs ?? []
for (relayURL, active) in subscriptions where active[id] != nil {
relayURLs.insert(relayURL)
}
for (relayURL, pending) in pendingSubscriptions
where pending[id] != nil {
relayURLs.insert(relayURL)
}
messageHandlers.removeValue(forKey: id)
subscriptionGenerations.removeValue(forKey: id)
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
closeSubscription(id: id, on: relayURLs)
}
private func closeSubscription(
id: String,
on relayURLs: Set<String>
) {
guard !relayURLs.isEmpty else { return }
for relayURL in relayURLs {
subscriptions[relayURL]?.removeValue(forKey: id)
pendingSubscriptions[relayURL]?.removeValue(forKey: id)
}
let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req)
guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return }
// Send unsubscribe to all relays
for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
for relayURL in relayURLs {
if let connection = connections[relayURL] {
connection.send(.string(messageString)) { _ in
// Local state is cleared before sending so callers can re-subscribe immediately.
// Local state is cleared first so a later same-ID REQ can
// register immediately and stale callbacks remain inert.
}
}
}
@ -1008,10 +1063,14 @@ final class NostrRelayManager: ObservableObject {
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
guard !requestState.relayURLs.isEmpty,
let generation = subscriptionGenerations[id]
else {
return requestState.relayURLs.isEmpty
}
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id]?.messageString == requestState.messageString ||
subscriptions[url]?.contains(id) == true
subscriptions[url]?[id] == generation
}
}
@ -1211,12 +1270,32 @@ final class NostrRelayManager: ObservableObject {
/// active subscription targeting this relay must be re-sent.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let connection = connections[relayUrl] else { return }
var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString)
for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil {
toSend[id] = state.messageString
var toSend: [
String: (messageString: String, generation: UInt64)
] = [:]
for (id, pending) in pendingSubscriptions[relayUrl] ?? [:] {
guard let state = subscriptionRequestState[id],
state.relayURLs.contains(relayUrl),
state.messageString == pending.messageString,
let generation = subscriptionGenerations[id]
else {
pendingSubscriptions[relayUrl]?.removeValue(forKey: id)
continue
}
toSend[id] = (pending.messageString, generation)
}
for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true {
for (id, state) in subscriptionRequestState
where state.relayURLs.contains(relayUrl)
{
guard let generation = subscriptionGenerations[id] else {
continue
}
toSend[id] = (state.messageString, generation)
}
for (id, request) in toSend {
let messageString = request.messageString
let generation = request.generation
if subscriptions[relayUrl]?[id] == generation {
// Already subscribed on this relay (e.g. a tracker promoted
// after an earlier flush): its EOSE is coming, count it.
markEOSESubscribed(id: id, relayUrl: relayUrl)
@ -1236,12 +1315,26 @@ final class NostrRelayManager: ObservableObject {
// Keep the pending entry; the next (re)connect retries it.
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
// A stale completion from a socket that has since been
// replaced must not mark the subscription active, or
// the next connection would skip replaying it.
guard let connection, self.connections[relayUrl] === connection else { return }
self.subscriptions[relayUrl, default: []].insert(id)
self.pendingSubscriptions[relayUrl]?.removeValue(forKey: id)
// Old sockets and old same-ID filters can complete
// after a replacement has already become canonical.
guard let connection,
self.connections[relayUrl] === connection,
self.subscriptionGenerations[id] == generation,
let desired =
self.subscriptionRequestState[id],
desired.relayURLs.contains(relayUrl),
desired.messageString == messageString
else {
return
}
self.subscriptions[relayUrl, default: [:]][id] =
generation
if self.pendingSubscriptions[relayUrl]?[id]?
.messageString == messageString
{
self.pendingSubscriptions[relayUrl]?
.removeValue(forKey: id)
}
}
}
}
@ -1286,24 +1379,49 @@ final class NostrRelayManager: ObservableObject {
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
private func precheckInboundEvent(
subscriptionID: String,
eventID: String,
relayUrl: String
) -> UInt64? {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
guard messageHandlers[subscriptionID] != nil,
let generation = subscriptionGenerations[subscriptionID]
else {
// Relays are untrusted and can invent subscription IDs. Do not
// spend signature work or mutate dedup state for unsolicited
// events.
return nil
}
guard !eventID.isEmpty else { return generation }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
return nil
}
return true
return generation
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
private func deliverVerifiedInboundEvent(
subscriptionID subId: String,
subscriptionGeneration: UInt64,
event: NostrEvent,
from relayUrl: String
) {
guard subscriptionGenerations[subId] == subscriptionGeneration,
let handler = messageHandlers[subId]
else {
// The event was admitted by a subscription that has since been
// retired. A replacement reusing the same wire ID is a different
// lifecycle and must neither receive nor dedup against this event.
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
@ -1314,11 +1432,7 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
handler(event)
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
@ -1344,10 +1458,20 @@ final class NostrRelayManager: ObservableObject {
}
}
case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success {
let durablyAccepted =
success
|| (!success && reason.hasPrefix("duplicate:"))
resolveConfirmedSend(
eventID: eventId,
relayURL: relayUrl,
accepted: durablyAccepted
)
if durablyAccepted {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
SecureLogger.debug(
"✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: .session
)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
@ -1822,7 +1946,7 @@ enum NostrRequest: Encodable {
}
}
struct NostrFilter: Encodable {
struct NostrFilter: Codable {
var ids: [String]?
var authors: [String]?
var kinds: [Int]?
@ -1831,7 +1955,7 @@ struct NostrFilter: Encodable {
var limit: Int?
// Tag filters - stored internally but encoded specially
fileprivate var tagFilters: [String: [String]]?
var tagFilters: [String: [String]]?
init() {
// Default initializer
@ -1841,6 +1965,29 @@ struct NostrFilter: Encodable {
enum CodingKeys: String, CodingKey {
case ids, authors, kinds, since, until, limit
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
self.ids = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "ids"))
self.authors = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "authors"))
self.kinds = try container.decodeIfPresent([Int].self, forKey: DynamicCodingKey(stringValue: "kinds"))
self.since = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "since"))
self.until = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "until"))
self.limit = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "limit"))
// Decode tag filters (#p, #d, etc) into internal storage without the leading '#'.
var decodedTagFilters: [String: [String]] = [:]
for key in container.allKeys {
let name = key.stringValue
guard name.hasPrefix("#") else { continue }
let tag = String(name.dropFirst())
if let values = try container.decodeIfPresent([String].self, forKey: key) {
decodedTagFilters[tag] = values
}
}
self.tagFilters = decodedTagFilters.isEmpty ? nil : decodedTagFilters
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: DynamicCodingKey.self)

View File

@ -97,6 +97,10 @@ enum NoisePayloadType: UInt8 {
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
// Double-ratchet invite/response events exchanged only inside an
// authenticated BLE Noise session. 0x12 is already vouch on current
// clients, so the unreleased prototype value must not be reused.
case ndrEvent = 0x22 // UTF-8 Nostr event JSON or compact invite URL
/// #1434 briefly used 0x09 before release. Accept it while prerelease
/// builds age out, but never emit it. Decoders canonicalize both values to
@ -125,6 +129,7 @@ enum NoisePayloadType: UInt8 {
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
case .ndrEvent: return "ndrEvent"
}
}
}

View File

@ -53,12 +53,20 @@ struct BLENoisePacketHandlerEnvironment {
_ payload: Data,
_ sessionGeneration: UUID
) -> Void
/// Authorizes generation-sensitive application payloads against the
/// exact session generation that decrypted them.
let authorizeNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ sessionGeneration: UUID
) -> Bool
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ payload: Data,
_ timestamp: Date
_ timestamp: Date,
_ sessionGeneration: UUID
) -> Void
}
@ -238,8 +246,26 @@ final class BLENoisePacketHandler {
return
}
guard env.authorizeNoisePayload(
peerID,
noisePayloadType,
decryption.sessionGeneration
) else {
SecureLogger.warning(
"Dropping Noise payload without capability proof for its decrypting generation",
category: .security
)
return
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
env.deliverNoisePayload(
peerID,
noisePayloadType,
Data(payloadData),
ts,
decryption.sessionGeneration
)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(

View File

@ -94,9 +94,14 @@ enum BLEOutboundFragmentPlanner {
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
}
let linkBoundedChunk = min(
requestedMaxChunk ?? calculatedChunk,
calculatedChunk
)
return (
fragmentVersion: fragmentVersion,
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
chunkSize: max(minimumChunkSize, linkBoundedChunk)
)
}

View File

@ -9,6 +9,8 @@ struct BLEOutboundFragmentTransferRequest {
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
let requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
init(
packet: BitchatPacket,
@ -17,7 +19,9 @@ struct BLEOutboundFragmentTransferRequest {
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) {
self.packet = packet
self.pad = pad
@ -26,6 +30,8 @@ struct BLEOutboundFragmentTransferRequest {
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
self.requiredAuthenticatedTransportState =
requiredAuthenticatedTransportState
}
var resolvedTransferId: String? {
@ -43,6 +49,22 @@ struct BLEOutboundFragmentTransferRequest {
}
}
enum BLEAuthenticatedTransportAdmission {
static func isCurrent(
expected: AuthenticatedPeerTransportState,
current: AuthenticatedPeerTransportState?
) -> Bool {
current == expected
}
static func writePriority(
ordinaryPriority: BLEOutboundWritePriority,
requiresExactGeneration: Bool
) -> BLEOutboundWritePriority {
requiresExactGeneration ? .high : ordinaryPriority
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.

View File

@ -2,7 +2,11 @@ import BitFoundation
import Foundation
enum BLEOutboundPacketPolicy {
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
private static let fragmentFrameOverhead =
BinaryProtocol.v1HeaderSize
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
+ 13
static func messageID(for packet: BitchatPacket) -> String {
BLEIngressLinkRegistry.messageID(for: packet)

View File

@ -287,6 +287,7 @@ final class BLEService: NSObject {
/// Fired (off-main) when a signature-verified announce is processed
/// the bridge courier watch refreshes its tag set on new arrivals.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
private let doubleRatchetEnabled: Bool
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@ -355,8 +356,9 @@ final class BLEService: NSObject {
// MARK: - Identity
private var noiseService: NoiseEncryptionService
/// Injected so tests can compress the quarantine/rollback window;
/// production always passes the security-constant default.
/// Injected so tests can isolate bounded handshake windows under load;
/// production uses the security-constant defaults.
private let noiseHandshakeTimeout: TimeInterval
private let noiseResponderHandshakeTimeout: TimeInterval
private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
@ -507,6 +509,9 @@ final class BLEService: NSObject {
initializeBluetoothManagers: Bool = true,
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
startSuspendedForPanicRecovery: Bool = false,
doubleRatchetEnabled: Bool = DoubleRatchetFeature.isEnabled,
noiseHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
@ -517,13 +522,21 @@ final class BLEService: NSObject {
self.incomingFileStore = incomingFileStore
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery
self.doubleRatchetEnabled = doubleRatchetEnabled
self.noiseHandshakeTimeout = noiseHandshakeTimeout
self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout
noiseService = NoiseEncryptionService(
keychain: keychain,
ordinaryHandshakeTimeout: noiseHandshakeTimeout,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
self.identityManager = identityManager
super.init()
localIdentityState.setCapability(
.doubleRatchet,
enabled: doubleRatchetEnabled
)
configureNoiseServiceCallbacks(for: noiseService)
refreshPeerIdentity()
@ -819,6 +832,7 @@ final class BLEService: NSObject {
let newNoise = NoiseEncryptionService(
keychain: keychain,
ordinaryHandshakeTimeout: noiseHandshakeTimeout,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
noiseService = newNoise
@ -1139,6 +1153,51 @@ final class BLEService: NSObject {
peerRegistry.capabilities(for: peerID)
}
func authenticatedPeerTransportState(
_ peerID: PeerID
) -> AuthenticatedPeerTransportState? {
let normalizedPeerID = peerID.toShort()
guard let generation = noiseService.sessionGeneration(for: normalizedPeerID),
let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID),
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame
else {
return nil
}
let session = privateMediaSessions.policyInputs(for: normalizedPeerID)
guard session.sessionGeneration == generation,
let observation = session.authenticatedState,
observation.sessionGeneration == generation,
observation.fingerprint.caseInsensitiveCompare(fingerprint)
== .orderedSame
else {
return nil
}
guard noiseService.sessionGeneration(for: normalizedPeerID) == generation else {
return nil
}
return AuthenticatedPeerTransportState(
capabilities: observation.capabilities,
sessionGeneration: generation,
noisePublicKey: publicKey
)
}
private func isNoisePayloadAuthorized(
_ type: NoisePayloadType,
from peerID: PeerID,
sessionGeneration: UUID
) -> Bool {
guard type == .ndrEvent else { return true }
guard doubleRatchetEnabled,
let authenticated = authenticatedPeerTransportState(peerID)
else {
return false
}
return authenticated.sessionGeneration == sessionGeneration
&& authenticated.capabilities.contains(.doubleRatchet)
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
@ -1968,12 +2027,17 @@ final class BLEService: NSObject {
private func broadcastPacket(
_ packet: BitchatPacket,
transferId: String? = nil,
requiresPrivateMediaAdmission: Bool = false
requiresPrivateMediaAdmission: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil,
admissionCompletion: ((Bool) -> Void)? = nil
) {
guard !isPanicSuspended else {
if requiresPrivateMediaAdmission, let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
if requiresPrivateMediaAdmission {
@ -1982,12 +2046,33 @@ final class BLEService: NSObject {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
if let requiredAuthenticatedTransportState {
guard requireNoiseAuthenticatedPeerLink,
let recipientPeerID =
PeerID(hexData: packet.recipientID),
BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(
recipientPeerID
)
)
else {
admissionCompletion?(false)
return
}
}
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
if requireNoiseAuthenticatedPeerLink {
// Durable NDR handoff must remain on the exact authenticated
// direct link. Routing or process-local spooling would make a
// positive admission result ambiguous.
packetToSend = packet
} else if let recipientPeerID = PeerID(hexData: packet.recipientID) {
packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
} else {
packetToSend = packet
@ -2043,6 +2128,7 @@ final class BLEService: NSObject {
if requiresPrivateMediaAdmission {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
@ -2056,6 +2142,7 @@ final class BLEService: NSObject {
if let transferId {
privateMediaTransferAdmissions.finish(transferId)
}
admissionCompletion?(false)
return
}
}
@ -2073,6 +2160,7 @@ final class BLEService: NSObject {
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
admissionCompletion?(true)
return
}
// App-initiated private media is already one opaque Noise ciphertext.
@ -2089,6 +2177,7 @@ final class BLEService: NSObject {
transferId: transferId,
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
)
admissionCompletion?(true)
return
}
if requiresPrivateMediaAdmission {
@ -2099,24 +2188,49 @@ final class BLEService: NSObject {
"Private media admission reached an unsupported non-directed packet shape",
category: .security
)
admissionCompletion?(false)
return
}
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
admissionCompletion?(false)
return
}
if requireNoiseAuthenticatedPeerLink {
guard packetToSend.type == MessageType.noiseEncrypted.rawValue,
let recipientPeerID =
PeerID(hexData: packetToSend.recipientID)
else {
admissionCompletion?(false)
return
}
let accepted = sendOnAllLinks(
packet: packetToSend,
data: data,
pad: padForBLE,
directedOnlyPeer: recipientPeerID,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
admissionCompletion?(accepted)
return
}
if packetToSend.type == MessageType.noiseEncrypted.rawValue {
sendEncrypted(packetToSend, data: data, pad: padForBLE)
admissionCompletion?(true)
return
}
sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
admissionCompletion?(true)
}
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }
var sentEncrypted = false
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
let outboundPriority =
BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Per-link limits for the specific peer
let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
@ -2226,10 +2340,20 @@ final class BLEService: NSObject {
centrals: [CBCentral],
characteristic: CBMutableCharacteristic,
context: String,
requiredAuthenticatedPeer: PeerID?
requiredAuthenticatedPeer: PeerID?,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
) -> Bool {
let eligible: [CBCentral]
if let peerID = requiredAuthenticatedPeer {
if let requiredAuthenticatedTransportState {
guard BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(peerID)
) else {
return false
}
}
eligible = centrals.filter { central in
let link = BLEIngressLinkID.central(central.identifier.uuidString)
return linkAuth.isAuthenticated(link, for: peerID)
@ -2265,9 +2389,24 @@ final class BLEService: NSObject {
pad: Bool,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) -> Bool {
guard !isPanicSuspended else { return false }
if let requiredAuthenticatedTransportState {
guard requireNoiseAuthenticatedPeerLink,
let directedOnlyPeer,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(
directedOnlyPeer
)
)
else {
return false
}
}
let ingressRecord = ingressLinks.record(for: packet)
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
@ -2277,7 +2416,20 @@ final class BLEService: NSObject {
guard !authenticatedLinks.isEmpty else { return false }
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
}
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Once an authenticated OOB fragment is admitted, the native action
// may be durably acknowledged. Keep those frames at FIFO-high
// priority so later traffic rejects itself instead of evicting an
// already-committed fragment from the bounded write queue.
let outboundPriority =
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority:
BLEOutboundPacketPolicy.priority(
for: packet,
data: data
),
requiresExactGeneration:
requireNoiseAuthenticatedPeerLink
)
let states = snapshotPeripheralStates()
// A link without a discovered characteristic cannot be written to
@ -2320,12 +2472,16 @@ final class BLEService: NSObject {
maxChunk: chunk,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
}
// If directed and we currently have no links to forward on, spool for a short window
if let only = plan.directedPeerHint,
if !requireDirectPeerLink,
!requireNoiseAuthenticatedPeerLink,
let only = plan.directedPeerHint,
plan.shouldSpoolDirectedPacket {
spoolDirectedPacket(packet, recipientPeerID: only)
}
@ -2343,7 +2499,9 @@ final class BLEService: NSObject {
to: s.peripheral,
characteristic: ch,
priority: outboundPriority,
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
) || acceptedByPhysicalLink
} else {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
@ -2360,7 +2518,9 @@ final class BLEService: NSObject {
centrals: targets,
characteristic: ch,
context: "directed",
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
) || acceptedByPhysicalLink
} else {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
@ -2383,7 +2543,9 @@ final class BLEService: NSObject {
_ packet: BitchatPacket,
to peerID: PeerID,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil
) -> Bool {
#if DEBUG
_test_onOutboundPacket?(packet)
@ -2395,7 +2557,9 @@ final class BLEService: NSObject {
pad: false,
directedOnlyPeer: peerID,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
}
@ -2865,6 +3029,63 @@ final class BLEService: NSObject {
sendNoisePayload(payload, to: peerID)
}
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
) {
guard let data = eventJson.data(using: .utf8),
!data.isEmpty,
data.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
else {
Task { @MainActor in completion(false) }
return
}
let normalizedPeerID = peerID.toShort()
messageQueue.async { [weak self] in
guard let self else {
Task { @MainActor in completion(false) }
return
}
guard self.doubleRatchetEnabled,
let authenticated = self.authenticatedPeerTransportState(normalizedPeerID),
authenticated == expectedTransportState,
authenticated.capabilities.contains(.doubleRatchet)
else {
SecureLogger.warning(
"NDR: dropping OOB send without the expected authenticated capability proof",
category: .security
)
Task { @MainActor in completion(false) }
return
}
let typedPayload = NoisePayload(type: .ndrEvent, data: data).encode()
do {
let packet = try self.makeEncryptedNoisePacket(
typedPayload,
to: normalizedPeerID,
expectedSessionGeneration: authenticated.sessionGeneration
)
self.broadcastPacket(
packet,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState:
expectedTransportState,
admissionCompletion: { accepted in
Task { @MainActor in completion(accepted) }
}
)
} catch {
SecureLogger.warning(
"NDR: dropping OOB send because the authenticated Noise generation changed",
category: .security
)
Task { @MainActor in completion(false) }
}
}
}
// MARK: Vouching over Noise
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
@ -4280,10 +4501,19 @@ extension BLEService {
for: normalizedPeerID,
expected: generation,
{
() -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in
guard privateMediaSessions.currentGeneration(for: normalizedPeerID) == generation else {
return (false, [])
() -> (
accepted: Bool,
completions: [@MainActor (PrivateMediaSendPolicy) -> Void],
observationChanged: Bool
) in
guard privateMediaSessions.currentGeneration(for: normalizedPeerID)
== generation
else {
return (false, [], false)
}
let previousObservation = privateMediaSessions
.policyInputs(for: normalizedPeerID)
.authenticatedState
// The generation lease (plus the engine slot this section
// holds) prevents rekey/session promotion from interleaving
@ -4314,9 +4544,19 @@ extension BLEService {
generation: generation,
capabilities: state.capabilities
) else {
return (false, [])
return (false, [], false)
}
return (true, completions)
let observationChanged = previousObservation.map {
$0.fingerprint.caseInsensitiveCompare(fingerprint)
!= .orderedSame
|| $0.sessionGeneration != generation
|| $0.capabilities != state.capabilities
} ?? true
return (
true,
completions,
observationChanged
)
}
), application.accepted else { return }
@ -4326,6 +4566,13 @@ extension BLEService {
let policy = privateMediaSendPolicy(to: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
completePrivateMediaPolicyResolution(application.completions, with: policy)
if application.observationChanged {
notifyUI { [weak self] in
self?.deliverTransportEvent(
.authenticatedPeerTransportStateUpdated(normalizedPeerID)
)
}
}
}
private func noteNoiseSessionCleared(for peerID: PeerID) {
@ -4396,7 +4643,8 @@ extension BLEService {
private func makeEncryptedNoisePacket(
_ typedPayload: Data,
to peerID: PeerID,
requiresAuthenticatedPrivateMediaReceipts: Bool = false
requiresAuthenticatedPrivateMediaReceipts: Bool = false,
expectedSessionGeneration: UUID? = nil
) throws -> BitchatPacket {
let encrypted: Data
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
@ -4413,6 +4661,12 @@ extension BLEService {
for: peerID,
sessionGeneration: provenGeneration
)
} else if let expectedSessionGeneration {
encrypted = try noiseService.encrypt(
typedPayload,
for: peerID,
sessionGeneration: expectedSessionGeneration
)
} else {
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
}
@ -5109,10 +5363,20 @@ extension BLEService {
to peripheral: CBPeripheral,
characteristic: CBCharacteristic,
priority: BLEOutboundWritePriority,
requiredAuthenticatedPeer: PeerID?
requiredAuthenticatedPeer: PeerID?,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState?
) -> Bool {
let uuid = peripheral.identifier.uuidString
if let peerID = requiredAuthenticatedPeer {
if let requiredAuthenticatedTransportState {
guard BLEAuthenticatedTransportAdmission.isCurrent(
expected: requiredAuthenticatedTransportState,
current: authenticatedPeerTransportState(peerID)
) else {
return false
}
}
let link = BLEIngressLinkID.peripheral(uuid)
guard linkBindings.peer(forPeripheralID: uuid) == peerID,
linkAuth.isAuthenticated(link, for: peerID) else {
@ -5426,6 +5690,8 @@ extension BLEService {
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false,
requiredAuthenticatedTransportState:
AuthenticatedPeerTransportState? = nil,
requiresPrivateMediaAdmission: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
@ -5435,7 +5701,9 @@ extension BLEService {
directedPeer: directedOnlyPeer,
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
requiredAuthenticatedTransportState
)
let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = onEngine {
@ -5562,12 +5830,30 @@ extension BLEService {
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
if let expected =
request.requiredAuthenticatedTransportState
{
guard let directedPeer = request.directedPeer,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: expected,
current:
self.authenticatedPeerTransportState(
directedPeer
)
)
else {
return false
}
}
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
return self.sendPacketDirected(
fragmentPacket,
to: directedPeer,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
requireNoiseAuthenticatedPeerLink:
request.requireNoiseAuthenticatedPeerLink,
requiredAuthenticatedTransportState:
request.requiredAuthenticatedTransportState
)
}
self.broadcastPacket(fragmentPacket)
@ -6865,7 +7151,14 @@ extension BLEService {
sessionGeneration: generation
)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
authorizeNoisePayload: { [weak self] peerID, type, generation in
self?.isNoisePayloadAuthorized(
type,
from: peerID,
sessionGeneration: generation
) ?? false
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp, generation in
if type == .privateFile {
self?.fileTransferHandler.handlePrivatePayload(
payload,
@ -6874,8 +7167,22 @@ extension BLEService {
)
return
}
guard self?.isNoisePayloadAuthorized(
type,
from: peerID,
sessionGeneration: generation
) == true else {
return
}
// Single main-actor hop delivering `.noisePayloadReceived`.
self?.notifyUI { [weak self] in
guard self?.isNoisePayloadAuthorized(
type,
from: peerID,
sessionGeneration: generation
) == true else {
return
}
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: type,

View File

@ -0,0 +1,14 @@
import Foundation
/// Rollout gate for the cross-platform double-ratchet transport.
///
/// Keep this disabled until the pairwise NDR implementations are reviewed and
/// ready to be enabled together on iOS and Android. Source builds and tests can
/// exercise it without advertising or routing production traffic.
enum DoubleRatchetFeature {
#if BITCHAT_ENABLE_NDR
static let isEnabled = true
#else
static let isEnabled = false
#endif
}

View File

@ -7,7 +7,7 @@ import Combine
@MainActor
final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable {
struct FavoriteRelationship: Codable, Equatable {
let peerNoisePublicKey: Data
let peerNostrPublicKey: String?
let peerNickname: String
@ -27,15 +27,43 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private static let pendingNostrIdentityRebindKey =
"chat.bitchat.favorites.ndr-rebind-journal"
private static let ndrRequiredNoiseKeysKey =
"chat.bitchat.favorites.ndr-required-noise-keys"
private struct PendingNostrIdentityRebind: Codable, Equatable {
let peerNoisePublicKey: Data
let oldNostrPublicKey: String
let targetRelationship: FavoriteRelationship
}
private struct NostrIdentityAssignment {
let requiresVerifiedCommit: Bool
}
private let keychain: KeychainManagerProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
private var nostrIdentityRebindAuthorizationOwner: UUID?
private var nostrIdentityRebindAuthorizationRequired =
DoubleRatchetFeature.isEnabled
private var authorizeNostrIdentityRebind:
((Data, String?, String) -> Bool)?
private var commitNostrIdentityRebind:
((Data, String, String) -> Bool)?
private var pendingNostrIdentityRebind:
PendingNostrIdentityRebind?
private var ndrRequiredNoiseKeys = Set<Data>()
private var ndrBindingStorageUnreadable = false
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
self.keychain = keychain
loadNdrRequiredNoiseKeys()
loadPendingNostrIdentityRebind()
loadFavorites()
// Update mutual favorites when favorites change
@ -45,6 +73,33 @@ final class FavoritesPersistenceService: ObservableObject {
}
.assign(to: &$mutualFavorites)
}
/// Installs the single app-lifetime NDR rebind authority. Ownership keeps
/// a retiring view model from clearing a newer view model's guard.
func installNostrIdentityRebindAuthorization(
owner: UUID,
required: Bool,
authorize: @escaping (Data, String?, String) -> Bool,
commit: @escaping (Data, String, String) -> Bool
) {
nostrIdentityRebindAuthorizationOwner = owner
nostrIdentityRebindAuthorizationRequired =
required || DoubleRatchetFeature.isEnabled
authorizeNostrIdentityRebind = authorize
commitNostrIdentityRebind = commit
recoverPendingNostrIdentityRebindIfPossible()
}
func removeNostrIdentityRebindAuthorization(owner: UUID) {
guard nostrIdentityRebindAuthorizationOwner == owner else {
return
}
nostrIdentityRebindAuthorizationOwner = nil
authorizeNostrIdentityRebind = nil
commitNostrIdentityRebind = nil
nostrIdentityRebindAuthorizationRequired =
DoubleRatchetFeature.isEnabled
}
/// Add or update a favorite
func addFavorite(
@ -52,27 +107,74 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil,
peerNickname: String
) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite mutation blocked by pending NDR rebind journal",
category: .security
)
return
}
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
let existing = favorites[peerNoisePublicKey]
let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey(
existing: existing?.peerNostrPublicKey,
requested: peerNostrPublicKey
)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNostrPublicKey:
effectiveNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNickname: peerNickname,
isFavorite: true,
theyFavoritedUs: existing?.theyFavoritedUs ?? false,
favoritedAt: existing?.favoritedAt ?? Date(),
lastUpdated: Date()
)
let assignment: NostrIdentityAssignment
if let effectiveNostrPublicKey,
existing?.peerNostrPublicKey != effectiveNostrPublicKey
{
guard let authorized = beginNostrIdentityAssignment(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: existing?.peerNostrPublicKey,
newNostrPublicKey: effectiveNostrPublicKey,
targetRelationship: relationship
) else {
SecureLogger.error(
"Refusing unauthorized favorite Nostr identity assignment",
category: .security
)
return
}
assignment = authorized
} else {
assignment = NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
// Log if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
}
favorites[peerNoisePublicKey] = relationship
saveFavorites()
var updatedFavorites = favorites
updatedFavorites[peerNoisePublicKey] = relationship
if assignment.requiresVerifiedCommit {
guard persistFavorites(updatedFavorites) else {
return
}
favorites = updatedFavorites
finishPendingNostrIdentityRebind()
} else {
favorites = updatedFavorites
saveFavorites()
}
// Notify observers
NotificationCenter.default.post(
@ -84,6 +186,15 @@ final class FavoritesPersistenceService: ObservableObject {
/// Remove a favorite
func removeFavorite(peerNoisePublicKey: Data) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite removal blocked by pending NDR rebind journal",
category: .security
)
return
}
guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
@ -124,6 +235,15 @@ final class FavoritesPersistenceService: ObservableObject {
peerNickname: String? = nil,
peerNostrPublicKey: String? = nil
) {
guard pendingNostrIdentityRebind?.peerNoisePublicKey
!= peerNoisePublicKey
else {
SecureLogger.error(
"Favorite mutation blocked by pending NDR rebind journal",
category: .security
)
return
}
let existing = favorites[peerNoisePublicKey]
// Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
@ -135,22 +255,52 @@ final class FavoritesPersistenceService: ObservableObject {
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey(
existing: existing?.peerNostrPublicKey,
requested: peerNostrPublicKey
)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNostrPublicKey:
effectiveNostrPublicKey ?? existing?.peerNostrPublicKey,
peerNickname: displayName,
isFavorite: existing?.isFavorite ?? false,
theyFavoritedUs: favorited,
favoritedAt: existing?.favoritedAt ?? Date(),
lastUpdated: Date()
)
let assignment: NostrIdentityAssignment
if let effectiveNostrPublicKey,
existing?.peerNostrPublicKey != effectiveNostrPublicKey
{
guard let authorized = beginNostrIdentityAssignment(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: existing?.peerNostrPublicKey,
newNostrPublicKey: effectiveNostrPublicKey,
targetRelationship: relationship
) else {
SecureLogger.error(
"Refusing unauthorized favorite Nostr identity assignment",
category: .security
)
return
}
assignment = authorized
} else {
assignment = NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
var updatedFavorites = favorites
if !relationship.isFavorite && !relationship.theyFavoritedUs {
// Neither side favorites, remove completely
favorites.removeValue(forKey: peerNoisePublicKey)
updatedFavorites.removeValue(forKey: peerNoisePublicKey)
// Removed - neither side favorites anymore
} else {
favorites[peerNoisePublicKey] = relationship
updatedFavorites[peerNoisePublicKey] = relationship
// Check if this creates a mutual favorite
if relationship.isMutual {
@ -158,7 +308,16 @@ final class FavoritesPersistenceService: ObservableObject {
}
}
saveFavorites()
if assignment.requiresVerifiedCommit {
guard persistFavorites(updatedFavorites) else {
return
}
favorites = updatedFavorites
finishPendingNostrIdentityRebind()
} else {
favorites = updatedFavorites
saveFavorites()
}
// Notify observers
NotificationCenter.default.post(
@ -193,6 +352,281 @@ final class FavoritesPersistenceService: ObservableObject {
}
return nil
}
func peerNostrPublicKeys(
excludingNoisePublicKey excludedNoisePublicKey: Data
) -> [String] {
favorites.compactMap { noisePublicKey, relationship in
guard noisePublicKey != excludedNoisePublicKey else {
return nil
}
return relationship.peerNostrPublicKey
}
}
/// Permanently requires pairwise NDR for this Noise identity. The pin is
/// intentionally independent of the Nostr identity so an identity rebind
/// can never reopen legacy kind-1059 fallback. Panic reset is the only
/// normal path that clears it.
@discardableResult
func markNdrRequired(for peerNoisePublicKey: Data) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard !ndrRequiredNoiseKeys.contains(peerNoisePublicKey) else {
return true
}
var updated = ndrRequiredNoiseKeys
updated.insert(peerNoisePublicKey)
guard persistNdrRequiredNoiseKeys(updated) else {
ndrBindingStorageUnreadable = true
SecureLogger.error(
"Could not durably pin favorite to double-ratchet transport",
category: .security
)
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
return false
}
ndrRequiredNoiseKeys = updated
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
return true
}
func isNdrRequired(for peerNoisePublicKey: Data) -> Bool {
ndrBindingStorageUnreadable
|| ndrRequiredNoiseKeys.contains(peerNoisePublicKey)
}
func isNdrRequired(for peerID: PeerID) -> Bool {
if ndrBindingStorageUnreadable {
return true
}
return ndrRequiredNoiseKeys.contains {
Self.peerID(peerID, matchesNoisePublicKey: $0)
}
}
/// A journal always suppresses legacy fallback, including after a build
/// turns the rollout gate back off. A permanent pin does the same after
/// the journal has been completed.
func isNdrFallbackBlocked(for peerID: PeerID) -> Bool {
if isNdrRequired(for: peerID) {
return true
}
guard let pendingNostrIdentityRebind else { return false }
return Self.peerID(
peerID,
matchesNoisePublicKey:
pendingNostrIdentityRebind.peerNoisePublicKey
)
}
/// Binding-dependent work is allowed only for an unambiguous durable
/// binding. If the target favorite was committed and only journal cleanup
/// failed, target OOB remains usable while legacy fallback stays blocked.
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind else { return true }
guard pendingNostrIdentityRebind.peerNoisePublicKey
== peerNoisePublicKey
else {
return true
}
return pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
== peerNostrPublicKey
&& favorites[peerNoisePublicKey]?.peerNostrPublicKey
== peerNostrPublicKey
}
func canUseNdrBinding(for peerID: PeerID) -> Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind,
Self.peerID(
peerID,
matchesNoisePublicKey:
pendingNostrIdentityRebind.peerNoisePublicKey
)
else {
return true
}
let targetNostrPublicKey = pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
return targetNostrPublicKey != nil
&& favorites[pendingNostrIdentityRebind.peerNoisePublicKey]?
.peerNostrPublicKey == targetNostrPublicKey
}
/// Account-mailbox kind-1059 is a legacy transport. Once a favorite has
/// durable pairwise state, or while its identity is being rebound, an
/// inbound legacy envelope from either identity is a downgrade and must
/// not be delivered under a virtual Nostr peer.
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool {
guard !ndrBindingStorageUnreadable,
let normalizedPeer =
Self.normalizedNostrPublicKey(peerNostrPublicKey)
else {
return false
}
if let journal = pendingNostrIdentityRebind {
if Self.normalizedNostrPublicKey(
journal.oldNostrPublicKey
) == normalizedPeer
|| journal.targetRelationship.peerNostrPublicKey.flatMap(
Self.normalizedNostrPublicKey
) == normalizedPeer
{
return false
}
}
for (noisePublicKey, relationship) in favorites {
guard ndrRequiredNoiseKeys.contains(noisePublicKey),
relationship.peerNostrPublicKey.flatMap(
Self.normalizedNostrPublicKey
) == normalizedPeer
else {
continue
}
return false
}
return true
}
var canActivateDoubleRatchetRelay: Bool {
guard !ndrBindingStorageUnreadable else { return false }
guard let pendingNostrIdentityRebind else { return true }
return favorites[pendingNostrIdentityRebind.peerNoisePublicKey]?
.peerNostrPublicKey
== pendingNostrIdentityRebind
.targetRelationship.peerNostrPublicKey
}
private func beginNostrIdentityAssignment(
peerNoisePublicKey: Data,
oldNostrPublicKey: String?,
newNostrPublicKey: String,
targetRelationship: FavoriteRelationship
) -> NostrIdentityAssignment? {
// A pending transaction reserves its target identity globally. Letting
// another favorite claim it can make crash recovery collision-fail
// forever.
guard pendingNostrIdentityRebind == nil else {
return nil
}
if let authorizeNostrIdentityRebind {
guard authorizeNostrIdentityRebind(
peerNoisePublicKey,
oldNostrPublicKey,
newNostrPublicKey
) else {
return nil
}
} else if nostrIdentityRebindAuthorizationRequired {
return nil
}
guard let oldNostrPublicKey,
ndrRequiredNoiseKeys.contains(peerNoisePublicKey)
else {
// Ordinary pre-NDR favorite identity changes remain legacy-capable.
// Only explicit durable session evidence creates the permanent pin
// and therefore requires destructive-retirement journaling.
return NostrIdentityAssignment(
requiresVerifiedCommit: false
)
}
// Durable pins outlive rollout switches. A pinned binding therefore
// always needs the same journaled retirement transaction, even in a
// build where new NDR sessions are dark.
guard !ndrBindingStorageUnreadable,
let commitNostrIdentityRebind
else {
return nil
}
let journal = PendingNostrIdentityRebind(
peerNoisePublicKey: peerNoisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
targetRelationship: targetRelationship
)
guard persistPendingNostrIdentityRebind(journal) else {
ndrBindingStorageUnreadable = true
return nil
}
pendingNostrIdentityRebind = journal
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
guard commitNostrIdentityRebind(
peerNoisePublicKey,
oldNostrPublicKey,
newNostrPublicKey
) else {
// The journal is deliberately retained. Native retirement may
// have partially succeeded, so clearing it could reopen legacy
// fallback against an ambiguous binding.
return nil
}
return NostrIdentityAssignment(requiresVerifiedCommit: true)
}
private static func peerID(
_ peerID: PeerID,
matchesNoisePublicKey noisePublicKey: Data
) -> Bool {
if let fullKey = Data(hexString: peerID.id),
fullKey == noisePublicKey
{
return true
}
return peerID.toShort()
== PeerID(publicKey: noisePublicKey).toShort()
}
private static func preservingEquivalentNostrKey(
existing: String?,
requested: String?
) -> String? {
guard let requested else { return nil }
guard let existing,
normalizedNostrPublicKey(existing)
== normalizedNostrPublicKey(requested),
normalizedNostrPublicKey(existing) != nil
else {
return requested
}
return existing
}
private static func normalizedNostrPublicKey(_ value: String) -> Data? {
let lowered = value.lowercased()
if lowered.hasPrefix("npub") {
guard let (hrp, data) = try? Bech32.decode(lowered),
hrp == "npub",
data.count == 32
else {
return nil
}
return data
}
guard lowered.count == 64,
lowered.allSatisfy(\.isHexDigit)
else {
return nil
}
return Data(hexString: lowered)
}
/// Clear all favorites - used for panic mode
func clearAllFavorites() {
@ -206,6 +640,17 @@ final class FavoritesPersistenceService: ObservableObject {
key: Self.storageKey,
service: Self.keychainService
)
keychain.delete(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
)
keychain.delete(
key: Self.ndrRequiredNoiseKeysKey,
service: Self.keychainService
)
pendingNostrIdentityRebind = nil
ndrRequiredNoiseKeys.removeAll()
ndrBindingStorageUnreadable = false
// Post notification for UI update
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
@ -213,36 +658,250 @@ final class FavoritesPersistenceService: ObservableObject {
// MARK: - Persistence
private func saveFavorites() {
let relationships = Array(favorites.values)
// Saving favorite relationships to keychain
@discardableResult
private func saveFavorites() -> Bool {
persistFavorites(favorites)
}
private func persistFavorites(
_ relationshipsByNoiseKey: [Data: FavoriteRelationship]
) -> Bool {
do {
let encoder = JSONEncoder()
let data = try encoder.encode(relationships)
// Store in keychain for security
keychain.save(
let relationships = relationshipsByNoiseKey.values.sorted {
$0.peerNoisePublicKey.hexEncodedString()
< $1.peerNoisePublicKey.hexEncodedString()
}
let data = try JSONEncoder().encode(relationships)
guard persistVerified(
key: Self.storageKey,
data: data,
service: Self.keychainService,
accessible: nil
)
// Successfully saved favorites
service: Self.keychainService
) else {
SecureLogger.error(
"Failed to verify persisted favorites",
category: .security
)
return false
}
return true
} catch {
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
return false
}
}
private func persistNdrRequiredNoiseKeys(
_ noiseKeys: Set<Data>
) -> Bool {
do {
let sorted = noiseKeys.sorted {
$0.hexEncodedString() < $1.hexEncodedString()
}
let data = try JSONEncoder().encode(sorted)
return persistVerified(
key: Self.ndrRequiredNoiseKeysKey,
data: data,
service: Self.keychainService
)
} catch {
return false
}
}
private func persistPendingNostrIdentityRebind(
_ journal: PendingNostrIdentityRebind
) -> Bool {
do {
let data = try JSONEncoder().encode(journal)
return persistVerified(
key: Self.pendingNostrIdentityRebindKey,
data: data,
service: Self.keychainService
)
} catch {
SecureLogger.error(
"Failed to encode favorite NDR rebind journal",
category: .security
)
return false
}
}
private func persistVerified(
key: String,
data: Data,
service: String
) -> Bool {
keychain.save(
key: key,
data: data,
service: service,
accessible: nil
)
guard case .success(let stored) = keychain.loadWithResult(
key: key,
service: service
) else {
return false
}
return stored == data
}
private func finishPendingNostrIdentityRebind() {
guard pendingNostrIdentityRebind != nil else { return }
keychain.delete(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
)
switch keychain.loadWithResult(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
) {
case .itemNotFound:
pendingNostrIdentityRebind = nil
case .success:
SecureLogger.error(
"Favorite NDR rebind journal could not be cleared",
category: .security
)
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
SecureLogger.error(
"Favorite NDR rebind journal clear could not be verified",
category: .security
)
}
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil
)
}
private func loadNdrRequiredNoiseKeys() {
switch keychain.loadWithResult(
key: Self.ndrRequiredNoiseKeysKey,
service: Self.keychainService
) {
case .itemNotFound:
return
case .success(let data):
guard let values = try? JSONDecoder().decode(
[Data].self,
from: data
),
values.allSatisfy({ $0.count == 32 })
else {
ndrBindingStorageUnreadable = true
return
}
ndrRequiredNoiseKeys = Set(values)
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
}
}
private func loadPendingNostrIdentityRebind() {
switch keychain.loadWithResult(
key: Self.pendingNostrIdentityRebindKey,
service: Self.keychainService
) {
case .itemNotFound:
return
case .success(let data):
guard let journal = try? JSONDecoder().decode(
PendingNostrIdentityRebind.self,
from: data
),
journal.peerNoisePublicKey.count == 32,
journal.targetRelationship.peerNoisePublicKey
== journal.peerNoisePublicKey,
journal.targetRelationship.peerNostrPublicKey != nil,
journal.targetRelationship.peerNostrPublicKey
!= journal.oldNostrPublicKey
else {
ndrBindingStorageUnreadable = true
return
}
pendingNostrIdentityRebind = journal
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
}
}
private func recoverPendingNostrIdentityRebindIfPossible() {
guard !ndrBindingStorageUnreadable,
let journal = pendingNostrIdentityRebind,
let targetNostrPublicKey =
journal.targetRelationship.peerNostrPublicKey,
let commitNostrIdentityRebind
else {
return
}
let currentNostrPublicKey =
favorites[journal.peerNoisePublicKey]?.peerNostrPublicKey
let normalizedCurrentNostrPublicKey =
currentNostrPublicKey.flatMap(Self.normalizedNostrPublicKey)
guard currentNostrPublicKey == nil
|| normalizedCurrentNostrPublicKey
== Self.normalizedNostrPublicKey(
journal.oldNostrPublicKey
)
|| normalizedCurrentNostrPublicKey
== Self.normalizedNostrPublicKey(
targetNostrPublicKey
),
authorizeNostrIdentityRebind?(
journal.peerNoisePublicKey,
journal.oldNostrPublicKey,
targetNostrPublicKey
) == true,
markNdrRequired(for: journal.peerNoisePublicKey),
commitNostrIdentityRebind(
journal.peerNoisePublicKey,
journal.oldNostrPublicKey,
targetNostrPublicKey
)
else {
return
}
var recovered = favorites
recovered[journal.peerNoisePublicKey] =
journal.targetRelationship
guard persistFavorites(recovered) else {
return
}
favorites = recovered
finishPendingNostrIdentityRebind()
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: [
"peerPublicKey": journal.peerNoisePublicKey
]
)
}
private func loadFavorites() {
// Loading favorites from keychain
guard let data = keychain.load(
let data: Data
switch keychain.loadWithResult(
key: Self.storageKey,
service: Self.keychainService
) else {
return
) {
case .itemNotFound:
return
case .success(let stored):
data = stored
case .accessDenied, .deviceLocked, .authenticationFailed,
.otherError:
ndrBindingStorageUnreadable = true
return
}
do {
@ -307,6 +966,7 @@ final class FavoritesPersistenceService: ObservableObject {
// Loaded relationships successfully
} catch {
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
ndrBindingStorageUnreadable = true
}
}
}

View File

@ -97,6 +97,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// migrations and panic deletion cannot silently miss them.
private static let additionalApplicationOwnedServices = [
"chat.bitchat.nostr",
"chat.bitchat.ndr.session-markers",
"chat.bitchat.favorites",
"chat.bitchat.outbox",
"com.bitchat.passwords",
@ -874,14 +875,25 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrSynchronizable as String: false
]) { _, new in new }
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
// Update in place so a failed replacement never destroys the last
// durable value. Rebind journals rely on the old favorites snapshot
// remaining readable when a write is rejected (locked device,
// entitlement failure, storage pressure, and similar errors).
let updateAttributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String:
accessible ?? Self.itemAccessibility
]
let updateStatus = SecItemUpdate(
primaryKeyQuery as CFDictionary,
updateAttributes as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
NSError(domain: "Keychain", code: Int(updateStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)

View File

@ -56,6 +56,22 @@ protocol MeshFileTransferring: AnyObject {
)
}
/// Pairwise double-ratchet bootstrap over one exact authenticated Noise
/// generation. The public announce bit is only a discovery hint; callers use
/// this surface to obtain the generation-bound proof and to send bootstrap
/// events on the matching authenticated direct link.
protocol MeshDoubleRatchetTransporting: AnyObject {
func authenticatedPeerTransportState(
_ peerID: PeerID
) -> AuthenticatedPeerTransportState?
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
)
}
/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`,
/// fire-and-forget inside the Noise session (private) or as a signed
/// ephemeral broadcast (public). Frames are only useful now the

File diff suppressed because it is too large Load Diff

View File

@ -888,6 +888,27 @@ final class NoiseEncryptionService {
return try sessionManager.encrypt(data, for: peerID)
}
/// Encrypts only under the exact authenticated session generation that
/// authorized the caller's capability decision. Rekey/removal between
/// proof lookup and encryption fails closed.
func encrypt(
_ data: Data,
for peerID: PeerID,
sessionGeneration: UUID
) throws -> Data {
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
return try sessionManager.encrypt(
data,
for: peerID,
expectedSessionGeneration: sessionGeneration
)
}
/// Encrypts a finalized private-media packet. Ordinary Noise application
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
/// the bounded `BitchatFilePacket` envelope and refuses every other typed

View File

@ -5,11 +5,54 @@ import Combine
// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport, @unchecked Sendable {
enum OutboundPrivateMessageTransport: String, Codable {
case ndr
case legacy1059
}
enum OutboundPrivateMessageError: LocalizedError {
case missingRecipientNpub(String)
case invalidRecipientNpub(String)
case missingSenderIdentity
case failedToEncodePacket
case failedToBuildFallbackEvent
case ndrSessionFailure
case expiringMessageRequiresNdrSession
var errorDescription: String? {
switch self {
case .missingRecipientNpub(let peerID):
return "Missing recipient Nostr public key for peer \(peerID)"
case .invalidRecipientNpub(let npub):
return "Recipient Nostr public key is invalid: \(npub)"
case .missingSenderIdentity:
return "Local Nostr identity is unavailable"
case .failedToEncodePacket:
return "Failed to encode embedded private-message packet"
case .failedToBuildFallbackEvent:
return "Failed to build fallback private-message event"
case .ndrSessionFailure:
return "The active double-ratchet session could not send"
case .expiringMessageRequiresNdrSession:
return "Disappearing messages require an active double-ratchet session"
}
}
}
private enum WrappedMessageOutcome {
case sent(OutboundPrivateMessageTransport)
case ndrFailed
case ndrRequired
case fallbackBuildFailed
}
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let canUseNdrBindingForPeerID: @MainActor (PeerID) -> Bool
let isNdrFallbackBlockedForPeerID: @MainActor (PeerID) -> Bool
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
@ -27,6 +70,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true },
isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
@ -38,6 +83,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.canUseNdrBindingForPeerID =
canUseNdrBindingForPeerID
self.isNdrFallbackBlockedForPeerID =
isNdrFallbackBlockedForPeerID
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
@ -55,6 +104,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
canUseNdrBindingForPeerID: {
FavoritesPersistenceService.shared
.canUseNdrBinding(for: $0)
},
isNdrFallbackBlockedForPeerID: {
FavoritesPersistenceService.shared
.isNdrFallbackBlocked(for: $0)
},
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
@ -83,7 +140,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
/// Ack pacing shared across transport instances. Geohash acks are sent
/// through short-lived transports created per ack
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
/// (`makeNostrTransport()`), so a per-instance queue would only
/// ever hold one item and never pace a burst (flagged by Codex on
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
/// tests get an isolated instance per `Dependencies` by default.
@ -129,6 +186,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
static let sharedAckPacer = AckPacer()
private let dependencies: Dependencies
private let ndrService: NdrNostrService
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
@ -143,16 +201,23 @@ final class NostrTransport: Transport, @unchecked Sendable {
init(
keychain _: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
ndrService: NdrNostrService? = nil,
dependencies: Dependencies? = nil
) {
self.dependencies = dependencies ?? .live(idBridge: idBridge)
self.ndrService = ndrService ?? .shared
setupObservers()
// Synchronously warm the cache to avoid startup race
let favorites = self.dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.filter {
$0.peerNostrPublicKey != nil
&& self.dependencies.canUseNdrBindingForPeerID(
PeerID(publicKey: $0.peerNoisePublicKey)
)
}
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
queue.sync(flags: .barrier) {
@ -186,7 +251,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
Task { @MainActor in
let favorites = dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.filter {
$0.peerNostrPublicKey != nil
&& dependencies.canUseNdrBindingForPeerID(
PeerID(publicKey: $0.peerNoisePublicKey)
)
}
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
self.queue.async(flags: .barrier) { [weak self] in
@ -253,15 +323,97 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
do {
_ = try sendPrivateMessageAndReturnTransport(
content,
to: peerID,
recipientNickname: recipientNickname,
messageID: messageID
)
} catch {
SecureLogger.error("NostrTransport: failed to send PM: \(error)", category: .session)
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
func sendPrivateMessage(
_ content: String,
to peerID: PeerID,
recipientNickname: String,
messageID: String,
expiresAtSeconds: UInt64
) {
Task { @MainActor in
do {
_ = try sendPrivateMessageAndReturnTransport(
content,
to: peerID,
recipientNickname: recipientNickname,
messageID: messageID,
expiresAtSeconds: expiresAtSeconds
)
} catch {
SecureLogger.error(
"NostrTransport: failed to send disappearing PM: \(error)",
category: .session
)
}
}
}
@MainActor
func sendPrivateMessageAndReturnTransport(
_ content: String,
to peerID: PeerID,
recipientNickname _: String,
messageID: String,
expiresAtSeconds: UInt64? = nil
) throws -> OutboundPrivateMessageTransport {
guard dependencies.canUseNdrBindingForPeerID(peerID) else {
throw OutboundPrivateMessageError.ndrSessionFailure
}
let requiresNdr =
dependencies.isNdrFallbackBlockedForPeerID(peerID)
guard let recipientNpub = resolveRecipientNpub(for: peerID) else {
throw OutboundPrivateMessageError.missingRecipientNpub(peerID.id)
}
guard let recipientHex = npubToHex(recipientNpub) else {
throw OutboundPrivateMessageError.invalidRecipientNpub(recipientNpub)
}
guard let senderIdentity = try dependencies.currentIdentity() else {
throw OutboundPrivateMessageError.missingSenderIdentity
}
SecureLogger.debug(
"NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))",
category: .session
)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(
content: content,
messageID: messageID,
recipientPeerID: peerID,
senderPeerID: senderPeerID
) else {
throw OutboundPrivateMessageError.failedToEncodePacket
}
switch sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
requiresNdr: requiresNdr,
expiresAtSeconds: expiresAtSeconds
) {
case .sent(let transport):
return transport
case .ndrFailed:
throw OutboundPrivateMessageError.ndrSessionFailure
case .ndrRequired:
if expiresAtSeconds != nil {
throw OutboundPrivateMessageError
.expiringMessageRequiresNdrSession
}
throw OutboundPrivateMessageError.ndrSessionFailure
case .fallbackBuildFailed:
throw OutboundPrivateMessageError.failedToBuildFallbackEvent
}
}
@ -287,7 +439,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
}
}
@ -319,7 +477,13 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
allowNdr: false
)
}
}
}
@ -342,15 +506,57 @@ extension NostrTransport {
/// Creates and sends a gift-wrapped private message event
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
@discardableResult
private func sendWrappedMessage(
content: String,
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
allowNdr: Bool = true,
requiresNdr: Bool = false,
expiresAtSeconds: UInt64? = nil
) -> WrappedMessageOutcome {
if allowNdr {
// Invites/responses travel only over an authenticated BLE Noise
// session; relay transport is used only after both clients have
// established the same ratchet session out of band.
ndrService.configureIfNeeded(identity: senderIdentity)
switch ndrService.send(
content,
to: recipientHex,
expiresAtSeconds: expiresAtSeconds
) {
case .sent:
return .sent(.ndr)
case .noSession:
if expiresAtSeconds != nil || requiresNdr {
SecureLogger.warning(
"NostrTransport: refusing legacy downgrade where NDR is required",
category: .security
)
return .ndrRequired
}
break
case .failed:
SecureLogger.error(
"NostrTransport: active NDR session failed; refusing legacy downgrade",
category: .security
)
return .ndrFailed
}
} else if expiresAtSeconds != nil || requiresNdr {
return .ndrRequired
}
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
return .fallbackBuildFailed
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
}
dependencies.sendEvent(event)
return .sent(.legacy1059)
}
@ -367,7 +573,13 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendWrappedMessage(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID),
@ -378,23 +590,44 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendWrappedMessage(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
requiresNdr:
dependencies.isNdrFallbackBlockedForPeerID(peerID)
)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
allowNdr: false
)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendWrappedMessage(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
allowNdr: false
)
}
}
}
@MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
guard dependencies.canUseNdrBindingForPeerID(peerID) else {
return nil
}
if let noiseKey = Data(hexString: peerID.id),
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
let npub = fav.peerNostrPublicKey {

View File

@ -65,6 +65,15 @@ struct MeshTopologySnapshot: Equatable {
let edges: [MeshTopologyEdge]
}
/// Capability proof carried inside one exact authenticated Noise generation.
/// Public announce capabilities are discovery hints and must never authorize
/// generation-sensitive payloads such as double-ratchet bootstrap.
struct AuthenticatedPeerTransportState: Equatable {
let capabilities: PeerCapabilities
let sessionGeneration: UUID
let noisePublicKey: Data
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@ -81,6 +90,9 @@ enum TransportEvent: @unchecked Sendable {
case peerSnapshotsUpdated([TransportPeerSnapshot])
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
case bluetoothStateUpdated(CBManagerState)
/// A new authenticated peer-state proof was accepted for the current
/// Noise generation. Identical echo packets do not re-emit this event.
case authenticatedPeerTransportStateUpdated(PeerID)
}
/// Downgrade-safe decision for a private-media recipient. Callers ask before
@ -300,12 +312,15 @@ extension BitchatDelegate {
didUpdateMessageDeliveryStatus(messageID, status: status)
case .bluetoothStateUpdated(let state):
didUpdateBluetoothState(state)
case .authenticatedPeerTransportStateUpdated:
break
}
}
}
extension BLEService: Transport {}
extension BLEService: MeshFileTransferring {}
extension BLEService: MeshDoubleRatchetTransporting {}
extension BLEService: MeshVoiceStreaming {}
extension BLEService: MeshCourierTransporting {}
extension BLEService: MeshGroupMessaging {}

View File

@ -94,6 +94,8 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID)
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID)
// MARK: System messages
func addMeshOnlySystemMessage(_ content: String)
@ -185,7 +187,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
makeGeohashNostrTransport().sendPrivateMessageGeohash(
makeNostrTransport().sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex,
from: identity,
@ -194,11 +196,24 @@ extension ChatViewModel: ChatPrivateConversationContext {
}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
makeNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
makeNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) {
makeNostrTransport().sendDeliveryAck(for: messageID, to: peerID)
}
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) {
let receipt = ReadReceipt(
originalMessageID: messageID,
readerID: myPeerID,
readerNickname: nickname
)
makeNostrTransport().sendReadReceipt(receipt, to: peerID)
}
func addSystemMessage(_ content: String) {
@ -226,7 +241,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
}
private func makeGeohashNostrTransport() -> NostrTransport {
private func makeNostrTransport() -> NostrTransport {
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
transport.senderPeerID = meshService.myPeerID
return transport
@ -486,7 +501,8 @@ final class ChatPrivateConversationCoordinator {
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
messageTimestamp: Date,
source: NostrPrivateMessageSource = .legacy1059
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
@ -494,7 +510,13 @@ final class ChatPrivateConversationCoordinator {
// Ack before the dedup guard: a re-sent copy means the sender may not
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
// actual sends.
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
sendDeliveryAckIfNeeded(
to: messageId,
senderPubKey: senderPubkey,
conversationPeerID: convKey,
from: id,
source: source
)
guard markInboundGeoDMSeen(messageId) else { return }
@ -555,7 +577,13 @@ final class ChatPrivateConversationCoordinator {
}
if isViewing {
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
sendReadReceiptIfNeeded(
to: messageId,
senderPubKey: senderPubkey,
conversationPeerID: convKey,
from: id,
source: source
)
}
if !isViewing && shouldMarkUnread {
@ -633,14 +661,50 @@ final class ChatPrivateConversationCoordinator {
}
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
func sendDeliveryAckIfNeeded(
to messageId: String,
senderPubKey: String,
conversationPeerID: PeerID,
from id: NostrIdentity,
source: NostrPrivateMessageSource
) {
guard context.markGeoDeliveryAckSent(messageId) else { return }
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
switch source {
case .ndr:
context.sendAccountNostrDeliveryAck(
for: messageId,
to: conversationPeerID
)
case .legacy1059:
context.sendGeohashDeliveryAck(
for: messageId,
toRecipientHex: senderPubKey,
from: id
)
}
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
func sendReadReceiptIfNeeded(
to messageId: String,
senderPubKey: String,
conversationPeerID: PeerID,
from id: NostrIdentity,
source: NostrPrivateMessageSource
) {
guard context.markReadReceiptSent(messageId) else { return }
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
switch source {
case .ndr:
context.sendAccountNostrReadReceipt(
for: messageId,
to: conversationPeerID
)
case .legacy1059:
context.sendGeohashReadReceipt(
messageId,
toRecipientHex: senderPubKey,
from: id
)
}
}
func handlePrivateMessage(_ message: BitchatMessage) {

View File

@ -84,6 +84,15 @@ protocol ChatTransportEventContext: AnyObject {
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
// MARK: Double-ratchet bootstrap
func bootstrapDoubleRatchetIfNeeded(for peerID: PeerID)
func handleNdrEventPayload(from peerID: PeerID, payload: Data)
}
extension ChatTransportEventContext {
func bootstrapDoubleRatchetIfNeeded(for _: PeerID) {}
func handleNdrEventPayload(from _: PeerID, payload _: Data) {}
}
extension ChatViewModel: ChatTransportEventContext {
@ -166,6 +175,494 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
private var ndrTransport: MeshDoubleRatchetTransporting? {
meshService as? MeshDoubleRatchetTransporting
}
func bootstrapDoubleRatchetIfNeeded(for peerID: PeerID) {
guard ndrService.isRolloutEnabled,
let ndrTransport,
let authenticated =
ndrTransport.authenticatedPeerTransportState(peerID),
authenticated.capabilities.contains(.doubleRatchet),
let relationship = favoritesService.getFavoriteStatus(
for: authenticated.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey),
favoritesService.canUseNdrBinding(
peerNoisePublicKey: authenticated.noisePublicKey,
peerNostrPublicKey: peerNostrKey
),
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
else {
return
}
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard prepareDoubleRatchetPeerBinding(
peerID: peerID,
noisePublicKey: authenticated.noisePublicKey,
peerPubkeyHex: peerPubkeyHex,
currentIdentityPubkeyHex: currentIdentity.publicKeyHex
) else {
return
}
if ndrService.hasPairwiseSession(with: peerPubkeyHex) {
guard favoritesService.markNdrRequired(
for: authenticated.noisePublicKey
) else {
return
}
ndrService.configureIfNeeded(identity: currentIdentity)
}
let shouldReleaseDeferredOutOfBand =
ndrOutOfBandGenerationByPeer[peerID]
!= authenticated.sessionGeneration
ndrOutOfBandGenerationByPeer[peerID] =
authenticated.sessionGeneration
sendNdrOutOfBandActions(
ndrService.pendingOutOfBandActions(
forAuthenticatedPeerPubkeyHex: peerPubkeyHex,
releaseDeferred: shouldReleaseDeferredOutOfBand
),
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated
)
if ndrService.hasPairwiseSession(with: peerPubkeyHex) {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
return
}
guard let invite = ndrService.currentInviteAction() else { return }
let inviteAttemptToken = [
authenticated.sessionGeneration.uuidString,
peerPubkeyHex,
invite.eventID
].joined(separator: "|")
guard ndrInviteAttemptTokenByPeer[peerID] != inviteAttemptToken else {
return
}
ndrInviteAttemptTokenByPeer[peerID] = inviteAttemptToken
SecureLogger.debug(
"NDR: OOB invite -> \(peerID.id.prefix(8))… peer=\(peerPubkeyHex.prefix(8))",
category: .session
)
sendNdrInvite(
invite,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated,
inviteAttemptToken: inviteAttemptToken
)
}
func handleNdrEventPayload(from peerID: PeerID, payload: Data) {
guard ndrService.isRolloutEnabled,
let ndrTransport,
let eventJson = String(data: payload, encoding: .utf8),
!eventJson.isEmpty,
let authenticated =
ndrTransport.authenticatedPeerTransportState(peerID),
authenticated.capabilities.contains(.doubleRatchet),
let relationship = favoritesService.getFavoriteStatus(
for: authenticated.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey),
favoritesService.canUseNdrBinding(
peerNoisePublicKey: authenticated.noisePublicKey,
peerNostrPublicKey: peerNostrKey
),
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
else {
return
}
let isExpectedBindingCurrent: () -> Bool = { [weak self] in
self?.isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: authenticated,
expectedPeerPubkeyHex: peerPubkeyHex
) == true
}
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard prepareDoubleRatchetPeerBinding(
peerID: peerID,
noisePublicKey: authenticated.noisePublicKey,
peerPubkeyHex: peerPubkeyHex,
currentIdentityPubkeyHex: currentIdentity.publicKeyHex
) else {
return
}
let actions = ndrService.processOutOfBandEventJson(
eventJson,
expectedPeerPubkeyHex: peerPubkeyHex,
authorization: isExpectedBindingCurrent,
persistEstablishedBinding: { [weak self] in
self?.favoritesService.markNdrRequired(
for: authenticated.noisePublicKey
) == true
}
)
sendNdrOutOfBandActions(
actions,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: authenticated
)
}
private func sendNdrOutOfBandActions(
_ actions: [NdrOutOfBandAction],
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState
) {
for action in actions {
sendNdrOutOfBandAction(
action,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
retryAttempt: 0
)
}
}
private func sendNdrInvite(
_ invite: NdrInviteAction,
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState,
inviteAttemptToken: String,
retryAttempt: Int = 0
) {
guard let ndrTransport,
ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken,
!ndrService.hasPairwiseSession(with: peerPubkeyHex),
ndrService.isCurrentInviteAction(invite),
isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
if ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
}
return
}
ndrTransport.sendNdrEvent(
to: peerID,
eventJson: invite.eventJson,
expectedTransportState: expectedTransportState,
completion: { [weak self] succeeded in
guard !succeeded, let self else { return }
guard self.ndrInviteAttemptTokenByPeer[peerID]
== inviteAttemptToken
else {
return
}
self.ndrService.scheduleHostTransientRetry(
after: retryAttempt
) {
[weak self] in
self?.sendNdrInvite(
invite,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
inviteAttemptToken: inviteAttemptToken,
retryAttempt: retryAttempt + 1
)
}
}
)
}
private func sendNdrOutOfBandAction(
_ action: NdrOutOfBandAction,
to peerID: PeerID,
peerPubkeyHex: String,
expectedTransportState: AuthenticatedPeerTransportState,
retryAttempt: Int
) {
guard let ndrTransport,
action.peerPubkeyHex == peerPubkeyHex,
isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
ndrService.completeOutOfBandAction(
action,
succeeded: false
)
return
}
let service = ndrService
ndrTransport.sendNdrEvent(
to: peerID,
eventJson: action.eventJson,
expectedTransportState: expectedTransportState,
completion: { [weak self] succeeded in
service.completeOutOfBandAction(
action,
succeeded: succeeded
)
guard !succeeded, let self else { return }
self.ndrService.scheduleHostTransientRetry(
after: retryAttempt
) {
[weak self] in
guard let self else { return }
guard self.isCurrentDoubleRatchetBinding(
peerID: peerID,
expectedTransportState: expectedTransportState,
expectedPeerPubkeyHex: peerPubkeyHex
)
else {
if self.ndrOutOfBandGenerationByPeer[peerID]
== expectedTransportState.sessionGeneration
{
self.ndrOutOfBandGenerationByPeer
.removeValue(forKey: peerID)
}
return
}
guard
service.prepareOutOfBandActionForRetry(action)
else {
return
}
self.sendNdrOutOfBandAction(
action,
to: peerID,
peerPubkeyHex: peerPubkeyHex,
expectedTransportState: expectedTransportState,
retryAttempt: retryAttempt + 1
)
}
}
)
}
private func isCurrentDoubleRatchetBinding(
peerID: PeerID,
expectedTransportState: AuthenticatedPeerTransportState,
expectedPeerPubkeyHex: String
) -> Bool {
guard let ndrTransport,
ndrTransport.authenticatedPeerTransportState(peerID)
== expectedTransportState,
expectedTransportState.capabilities.contains(.doubleRatchet),
let relationship = favoritesService.getFavoriteStatus(
for: expectedTransportState.noisePublicKey
),
relationship.isMutual,
let peerNostrKey = relationship.peerNostrPublicKey,
Self.ndrNostrPubkeyHex(from: peerNostrKey)
== expectedPeerPubkeyHex,
favoritesService.canUseNdrBinding(
peerNoisePublicKey:
expectedTransportState.noisePublicKey,
peerNostrPublicKey: peerNostrKey
)
else {
return false
}
return true
}
private func prepareDoubleRatchetPeerBinding(
peerID: PeerID,
noisePublicKey: Data,
peerPubkeyHex: String,
currentIdentityPubkeyHex: String
) -> Bool {
let identityPubkeyHex = currentIdentityPubkeyHex.lowercased()
if ndrBindingIdentityPubkeyHex != identityPubkeyHex {
ndrInviteAttemptTokenByPeer.removeAll()
ndrOutOfBandGenerationByPeer.removeAll()
ndrPeerPubkeyByNoiseKey.removeAll()
ndrBindingIdentityPubkeyHex = identityPubkeyHex
}
if let previousPeerPubkeyHex =
ndrPeerPubkeyByNoiseKey[noisePublicKey],
previousPeerPubkeyHex != peerPubkeyHex
{
guard ndrService.retirePeer(previousPeerPubkeyHex) else {
return false
}
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID)
}
ndrPeerPubkeyByNoiseKey[noisePublicKey] = peerPubkeyHex
return true
}
func authorizeDoubleRatchetFavoriteRebind(
noisePublicKey: Data,
oldNostrPublicKey: String?,
newNostrPublicKey: String
) -> Bool {
guard let newPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: newNostrPublicKey)
else {
// A malformed destination cannot be collision-checked.
return false
}
let oldPeerPubkeyHex: String?
if let oldNostrPublicKey {
guard let normalized =
Self.ndrNostrPubkeyHex(from: oldNostrPublicKey)
else {
// A malformed existing binding cannot be safely retired.
return false
}
oldPeerPubkeyHex = normalized
} else {
oldPeerPubkeyHex = nil
}
guard oldPeerPubkeyHex != newPeerPubkeyHex else { return true }
let otherFavoritePubkeys =
favoritesService.peerNostrPublicKeys(
excludingNoisePublicKey: noisePublicKey
)
.compactMap { Self.ndrNostrPubkeyHex(from: $0) }
guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else {
// A Nostr identity may have only one stable Noise binding. Without
// this, two radio identities could both authorize the same ratchet.
return false
}
guard let oldPeerPubkeyHex else {
// Initial and nil-to-value assignments have nothing to retire.
return true
}
guard ndrService.isRolloutEnabled else {
// FavoritesPersistenceService still journals and commits a
// previously pinned binding while rollout is dark. There is no
// new session to discover or pin on this path.
return true
}
guard let currentIdentity =
try? idBridge.getCurrentNostrIdentity()
else {
return false
}
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
guard ndrService.isConfigured else {
return false
}
if ndrService.hasPairwiseSession(with: oldPeerPubkeyHex) {
return favoritesService.markNdrRequired(
for: noisePublicKey
)
}
return true
}
func commitDoubleRatchetFavoriteRebind(
noisePublicKey: Data,
oldNostrPublicKey: String,
newNostrPublicKey: String
) -> Bool {
guard let oldPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: oldNostrPublicKey),
let newPeerPubkeyHex =
Self.ndrNostrPubkeyHex(from: newNostrPublicKey),
let currentIdentity =
try? idBridge.getCurrentNostrIdentity()
else {
return false
}
// Representation-only changes (hex npub or case) carry no
// retirement intent. Returning success also recovers journals written
// by an older build before equivalent keys were normalized.
guard oldPeerPubkeyHex != newPeerPubkeyHex else {
return true
}
let otherFavoritePubkeys =
favoritesService.peerNostrPublicKeys(
excludingNoisePublicKey: noisePublicKey
)
.compactMap { Self.ndrNostrPubkeyHex(from: $0) }
guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else {
return false
}
// Configuration and retirement are intentionally action-silent here:
// the durable rebind journal exists, but the target favorite has not
// been committed yet. Relay work resumes through the normal setup/send
// path only after FavoritesPersistenceService verifies that commit.
guard ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false,
allowDisabledMaintenance: true
) else {
return false
}
if !otherFavoritePubkeys.contains(oldPeerPubkeyHex),
!ndrService.retirePeer(
oldPeerPubkeyHex,
processPendingActions: false,
allowDisabledMaintenance: true
)
{
return false
}
let reboundPeerIDs = ndrOutOfBandGenerationByPeer.keys.filter {
ndrTransport?.authenticatedPeerTransportState($0)?
.noisePublicKey == noisePublicKey
}
for peerID in reboundPeerIDs {
ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID)
ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID)
}
ndrPeerPubkeyByNoiseKey[noisePublicKey] = newPeerPubkeyHex
ndrBindingIdentityPubkeyHex =
currentIdentity.publicKeyHex.lowercased()
return true
}
static func ndrNostrPubkeyHex(from npubOrHex: String) -> String? {
let lowered = npubOrHex.lowercased()
if lowered.hasPrefix("npub") {
guard let (hrp, data) = try? Bech32.decode(lowered),
hrp == "npub",
data.count == 32
else {
return nil
}
return data.hexEncodedString()
}
guard lowered.count == 64,
lowered.allSatisfy(\.isHexDigit)
else {
return nil
}
return lowered
}
}
final class ChatTransportEventCoordinator {
@ -281,6 +778,7 @@ final class ChatTransportEventCoordinator {
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
context.bootstrapDoubleRatchetIfNeeded(for: peerID)
}
func didDisconnectFromPeer(_ peerID: PeerID) {
@ -502,6 +1000,9 @@ private extension ChatTransportEventCoordinator {
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
case .ndrEvent:
context.handleNdrEventPayload(from: peerID, payload: payload)
case .voiceFrame:
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)

View File

@ -318,6 +318,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
let meshService: Transport
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
let ndrService: NdrNostrService
let favoritesService: FavoritesPersistenceService
/// Bounds NDR bootstrap retries to one chain for an exact authenticated
/// Noise generation. A changed generation or invite replaces the token.
var ndrInviteAttemptTokenByPeer: [PeerID: String] = [:]
/// Tracks which Noise generation last claimed durable OOB responses so
/// repeated bootstrap triggers cannot reset their retry budget.
var ndrOutOfBandGenerationByPeer: [PeerID: UUID] = [:]
/// A favorite's authenticated Noise key is the stable binding. If its
/// associated Nostr identity changes, retire only that old pairwise peer.
var ndrPeerPubkeyByNoiseKey: [Data: String] = [:]
var ndrBindingIdentityPubkeyHex: String?
private let ndrFavoriteRebindAuthorizationOwner = UUID()
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through.
@ -1104,7 +1117,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
locationManager: LocationChannelManager = .shared,
ndrService: NdrNostrService? = nil,
favoritesService: FavoritesPersistenceService? = nil
) {
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
let startSuspendedForRecovery: Bool
@ -1140,6 +1155,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
idBridge: idBridge,
identityManager: identityManager,
transport: meshService,
ndrService: ndrService,
favoritesService: favoritesService,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
@ -1159,6 +1176,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
transport: Transport,
ndrService: NdrNostrService? = nil,
favoritesService: FavoritesPersistenceService? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
@ -1173,11 +1192,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let resolvedNdrService = ndrService ?? .shared
let resolvedFavoritesService =
favoritesService ?? FavoritesPersistenceService.shared
let services = ChatViewModelServiceBundle(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
meshService: transport,
ndrService: resolvedNdrService,
outboxStore: outboxStore,
sfMetrics: sfMetrics
)
@ -1189,6 +1212,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
self.ndrService = resolvedNdrService
self.favoritesService = resolvedFavoritesService
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
@ -1243,6 +1268,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
_ = panicClearAllData(restartServices: false)
}
resolvedFavoritesService
.installNostrIdentityRebindAuthorization(
owner: ndrFavoriteRebindAuthorizationOwner,
required: resolvedNdrService.isRolloutEnabled,
authorize: { [weak self]
noisePublicKey,
oldNostrPublicKey,
newNostrPublicKey in
self?.authorizeDoubleRatchetFavoriteRebind(
noisePublicKey: noisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
newNostrPublicKey: newNostrPublicKey
) ?? false
},
commit: { [weak self]
noisePublicKey,
oldNostrPublicKey,
newNostrPublicKey in
self?.commitDoubleRatchetFavoriteRebind(
noisePublicKey: noisePublicKey,
oldNostrPublicKey: oldNostrPublicKey,
newNostrPublicKey: newNostrPublicKey
) ?? false
}
)
if networkActivationAllowed {
ChatViewModelBootstrapper(viewModel: self).configure()
}
@ -1251,6 +1302,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// MARK: - Deinitialization
deinit {
let owner = ndrFavoriteRebindAuthorizationOwner
let favoritesService = favoritesService
Task { @MainActor in
favoritesService
.removeNostrIdentityRebindAuthorization(owner: owner)
}
// No need to force UserDefaults synchronization
}
@ -1512,6 +1569,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@objc func handleFavoriteStatusChanged(_ notification: Notification) {
peerIdentityCoordinator.handleFavoriteStatusChanged(notification)
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
Task { @MainActor [weak self] in
guard let self,
let peer = unifiedPeerService.peers.first(where: {
$0.isConnected && $0.noisePublicKey == peerPublicKey
})
else {
return
}
bootstrapDoubleRatchetIfNeeded(for: peer.peerID)
}
}
// MARK: - App Lifecycle
@ -1580,6 +1648,22 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
queuedPrivateChatClears.removeAll(keepingCapacity: false)
privateChatClearInFlight = false
ndrInviteAttemptTokenByPeer.removeAll()
ndrOutOfBandGenerationByPeer.removeAll()
ndrPeerPubkeyByNoiseKey.removeAll()
ndrBindingIdentityPubkeyHex = nil
let ndrWipeCompleted: Bool
do {
try ndrService.resetForPanic()
ndrWipeCompleted = true
} catch {
ndrWipeCompleted = false
SecureLogger.error(
"Panic double-ratchet storage cleanup incomplete; recovery remains pending: \(error)",
category: .security
)
}
// Deny and release any clear-media confirmations before identities,
// message state, and local files are wiped.
cancelAllLegacyPrivateMediaConsents()
@ -1620,7 +1704,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
favoritesService.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk),
// our own queued outbox, the carried public history, and the
@ -1717,7 +1801,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
let panicCompleted: Bool
do {
try panicRecoveryOperations.wipeMedia(recoveryIntent)
if keychainWipeCompleted {
if keychainWipeCompleted && ndrWipeCompleted {
try panicRecoveryOperations.complete()
panicCompleted = true
SecureLogger.info(
@ -2101,6 +2185,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
case .bluetoothStateUpdated(let state):
updateBluetoothState(state)
case .authenticatedPeerTransportStateUpdated(let peerID):
bootstrapDoubleRatchetIfNeeded(for: peerID)
}
}

View File

@ -18,6 +18,7 @@ struct ChatViewModelServiceBundle {
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
meshService: Transport,
ndrService: NdrNostrService,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
) {
@ -28,7 +29,11 @@ struct ChatViewModelServiceBundle {
idBridge: idBridge,
identityManager: identityManager
)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
let nostrTransport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService
)
nostrTransport.senderPeerID = meshService.myPeerID
let messageRouter = MessageRouter(
transports: [meshService, nostrTransport],
@ -79,6 +84,7 @@ final class ChatViewModelBootstrapper {
configureGateway()
configureBridge()
configureBridgeCourier()
bindNdrRelayRetry()
bindTeleportState()
requestNotifications()
registerObservers()
@ -634,6 +640,17 @@ private extension ChatViewModelBootstrapper {
courier.refresh()
}
func bindNdrRelayRetry() {
NostrRelayManager.shared.$isDMRelayConnected
.removeDuplicates()
.filter { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
viewModel?.ndrService.retryRelayActions()
}
.store(in: &viewModel.cancellables)
}
private static let bridgeSubscriptionID = "bridge-rendezvous"
private static let courierDropSubscriptionID = "bridge-courier-drops"

View File

@ -62,6 +62,56 @@ extension ChatViewModel {
@MainActor
func setupNostrMessageHandling() {
if favoritesService.canActivateDoubleRatchetRelay,
let currentIdentity = try? idBridge.getCurrentNostrIdentity()
{
ndrService.configureIfNeeded(
identity: currentIdentity,
processPendingActions: false
)
if ndrService.isRolloutEnabled {
for relationship in favoritesService.favorites.values {
guard let peerNostrPublicKey =
relationship.peerNostrPublicKey,
let peerPubkeyHex =
Self.ndrNostrPubkeyHex(
from: peerNostrPublicKey
),
ndrService.hasPairwiseSession(
with: peerPubkeyHex
)
else {
continue
}
guard favoritesService.markNdrRequired(
for: relationship.peerNoisePublicKey
) else {
ndrService.onDecryptedMessage = nil
nostrCoordinator.subscriptions
.setupNostrMessageHandling()
return
}
}
}
guard favoritesService.canActivateDoubleRatchetRelay else {
ndrService.onDecryptedMessage = nil
nostrCoordinator.subscriptions.setupNostrMessageHandling()
return
}
ndrService.onDecryptedMessage = { [weak self] message, completion in
guard let self else {
completion(.retry)
return
}
self.nostrCoordinator.inbound.handleNdrDecryptedMessage(
message,
completion: completion
)
}
ndrService.configureIfNeeded(identity: currentIdentity)
} else {
ndrService.onDecryptedMessage = nil
}
nostrCoordinator.subscriptions.setupNostrMessageHandling()
}

View File

@ -34,14 +34,16 @@ extension ChatViewModel {
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
messageTimestamp: Date,
source: NostrPrivateMessageSource = .legacy1059
) {
privateConversationCoordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
messageTimestamp: messageTimestamp,
source: source
)
}

View File

@ -2,6 +2,11 @@ import BitFoundation
import BitLogger
import Foundation
enum NostrPrivateMessageSource: Equatable {
case legacy1059
case ndr
}
/// The narrow surface `NostrInboundPipeline` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
@ -24,6 +29,11 @@ protocol NostrInboundPipelineContext: AnyObject {
/// All favorite relationships, used to bridge a Nostr pubkey back to a
/// Noise key on the inbound DM path.
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool
// MARK: Presence & key mapping
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
@ -46,12 +56,26 @@ protocol NostrInboundPipelineContext: AnyObject {
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
messageTimestamp: Date,
source: NostrPrivateMessageSource
)
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
}
extension NostrInboundPipelineContext {
func canUseNdrBinding(
peerNoisePublicKey _: Data,
peerNostrPublicKey _: String
) -> Bool {
true
}
func canAcceptLegacyNostrDM(from _: String) -> Bool {
true
}
}
extension ChatViewModel: NostrInboundPipelineContext {
// `currentGeohash`, the identity/blocking members, key mapping, and the
// inbound message handlers already have witnesses on `ChatViewModel`.
@ -62,7 +86,23 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(FavoritesPersistenceService.shared.favorites.values)
Array(favoritesService.favorites.values)
}
func canUseNdrBinding(
peerNoisePublicKey: Data,
peerNostrPublicKey: String
) -> Bool {
favoritesService.canUseNdrBinding(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey
)
}
func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool {
favoritesService.canAcceptLegacyNostrDM(
from: peerNostrPublicKey
)
}
func recordProcessedNostrEvent(_ eventID: String) {
@ -92,6 +132,7 @@ extension ChatViewModel: NostrInboundPipelineContext {
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private let now: @MainActor () -> Date
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
@ -110,9 +151,14 @@ final class NostrInboundPipeline {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
init(
context: any NostrInboundPipelineContext,
presence: GeoPresenceTracker,
now: @escaping @MainActor () -> Date = Date.init
) {
self.context = context
self.presence = presence
self.now = now
}
@MainActor
@ -387,7 +433,8 @@ final class NostrInboundPipeline {
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
messageTimestamp: messageTimestamp,
source: .legacy1059
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
@ -397,7 +444,9 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate,
.vouch, .ndrEvent, .voiceFrame, .privateFile,
.authenticatedPeerState:
break
}
}
@ -419,6 +468,51 @@ final class NostrInboundPipeline {
}
}
@MainActor
func handleNdrDecryptedMessage(
_ message: NdrDecryptedMessage,
completion: @escaping NdrDeliveryCompletion
) {
guard let context else {
completion(.retry)
return
}
let innerEvent = message.event
guard !context.hasProcessedNostrEvent(innerEvent.id) else {
completion(.consumed)
return
}
let wipeGeneration = self.wipeGeneration
guard let currentIdentity = context.currentNostrIdentity() else {
completion(.retry)
return
}
Task { [weak self] in
guard let self else {
completion(.retry)
return
}
let disposition = await self.processDecryptedNostrDMContent(
innerEvent.content,
senderPubkey: message.senderPubkeyHex,
rumorTimestamp: innerEvent.created_at,
currentIdentity: currentIdentity,
wipeGeneration: wipeGeneration,
expiresAtSeconds: message.expiresAtSeconds,
source: .ndr
)
guard self.wipeGeneration == wipeGeneration else {
completion(.retry)
return
}
if disposition == .consumed {
context.recordProcessedNostrEvent(innerEvent.id)
}
completion(disposition)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
@ -444,64 +538,128 @@ final class NostrInboundPipeline {
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
if content.hasPrefix("verify:") {
let acceptsLegacyDM = await MainActor.run {
context.canAcceptLegacyNostrDM(
from: senderPubkey
)
}
guard acceptsLegacyDM else {
SecureLogger.warning(
"Rejected legacy account DM for pairwise-only binding",
category: .security
)
return
}
if content.hasPrefix("bitchat1:") {
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: senderPubkey)
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
// Group state travels only over mesh Noise sessions
// in v1; group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
await processDecryptedNostrDMContent(
content,
senderPubkey: senderPubkey,
rumorTimestamp: rumorTimestamp,
currentIdentity: currentIdentity,
wipeGeneration: wipeGeneration,
source: .legacy1059
)
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
@discardableResult
private func processDecryptedNostrDMContent(
_ content: String,
senderPubkey: String,
rumorTimestamp: Int,
currentIdentity: NostrIdentity,
wipeGeneration: UInt64,
expiresAtSeconds: UInt64? = nil,
source: NostrPrivateMessageSource
) async -> NdrDeliveryDisposition {
guard let context else { return .retry }
if content.hasPrefix("verify:") {
return .consumed
}
guard content.hasPrefix("bitchat1:") else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
return .consumed
}
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return .consumed
}
let routingPubkey = senderPubkey
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: routingPubkey)
}
if source == .ndr, actualSenderNoiseKey == nil {
// Keep the native delivery durable until the favorite binding
// journal is recovered. Falling through to a virtual Nostr peer
// would bypass the fail-closed pairwise identity binding.
return .retry
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString())
?? PeerID(nostr_: routingPubkey)
guard packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return .consumed
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
return await MainActor.run {
guard self.wipeGeneration == wipeGeneration else {
return .retry
}
if let expiresAtSeconds,
self.now().timeIntervalSince1970
>= TimeInterval(expiresAtSeconds)
{
// The native delivery remains durable while decoding hops
// actors. Recheck immediately before every app mutation so a
// message that expires during that work drains without ever
// being mapped, persisted, or notified.
return .consumed
}
context.registerNostrKeyMapping(routingPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp,
source: source
)
case .delivered:
context.handleDelivered(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
case .readReceipt:
context.handleReadReceipt(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID
)
// These payloads are mesh-only and must never be tunneled through
// either private-relay envelope.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate,
.vouch, .ndrEvent, .voiceFrame, .privateFile,
.authenticatedPeerState:
break
}
return .consumed
}
}
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
/// store. Lives here because the inbound DM path needs it per message.
@MainActor
@ -524,6 +682,13 @@ final class NostrInboundPipeline {
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
guard context.canUseNdrBinding(
peerNoisePublicKey:
relationship.peerNoisePublicKey,
peerNostrPublicKey: storedNostrKey
) else {
continue
}
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}

View File

@ -81,6 +81,16 @@ private func waitUntil(
}
}
@MainActor
private func drainArchitecturePublicationQueue() async {
for _ in 0..<5 {
await withCheckedContinuation { continuation in
DispatchQueue.main.async { continuation.resume() }
}
await Task.yield()
}
}
@Suite("App Architecture Tests", .serialized)
struct AppArchitectureTests {
@ -671,7 +681,8 @@ struct AppArchitectureTests {
@Test("PeerListModel publishes mesh and geohash directory state")
@MainActor
func peerListModelPublishesDirectoryState() async {
let viewModel = makeArchitectureViewModel()
let locationManager = makeArchitectureLocationManager()
let viewModel = makeArchitectureViewModel(locationManager: locationManager)
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
@ -681,7 +692,6 @@ struct AppArchitectureTests {
let otherPeerID = PeerID(str: "0011223344556677")
let geohash = "9q8yy"
let remoteGeoID = String(repeating: "b", count: 64)
let locationManager = makeArchitectureLocationManager()
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let otherNoiseKey = Data((0..<32).map(UInt8.init))
let verifiedFingerprint = otherNoiseKey.sha256Fingerprint()
@ -711,17 +721,19 @@ struct AppArchitectureTests {
locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash)))
await waitUntil {
if case .location(let channel) = locationManager.selectedChannel {
return channel.geohash == geohash && !viewModel.allPeers.isEmpty
return channel.geohash == geohash &&
viewModel.currentGeohash == geohash &&
!viewModel.allPeers.isEmpty
}
return false
}
viewModel.participantTracker.setActiveGeohash(geohash)
viewModel.teleportedGeo = Set([remoteGeoID])
viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash)
if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() {
viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash)
}
await drainArchitecturePublicationQueue()
viewModel.teleportedGeo = Set([remoteGeoID])
let peerListModel = PeerListModel(
chatViewModel: viewModel,

View File

@ -855,10 +855,17 @@ struct BLEServiceCoreTests {
/// has to wait for the convergence handshake and use its new session.
@Test
func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws {
let ble = makeService(noiseResponderHandshakeTimeout: 0.3)
let ble = makeService(
noiseHandshakeTimeout: TestConstants.settleTimeout * 2,
noiseResponderHandshakeTimeout: 0.3
)
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let reconciled = SessionReconcileCounter()
ble._test_onPrivateMediaSessionReconciled = reconciled.record
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
// Establish BLE as responder so the inbound reconnect below is not
// coalesced by the initiator-completion grace path.
@ -881,20 +888,33 @@ struct BLEServiceCoreTests {
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
let initialEncryptedFrameSent = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 1 },
timeout: TestConstants.settleTimeout
)
try #require(initialEncryptedFrameSent)
#expect(outbound.count(ofType: .noiseEncrypted) == 1)
let initialReconcileRan = await TestHelpers.waitUntil(
{ reconciled.count(for: alicePeerID) == 1 },
timeout: TestConstants.settleTimeout
)
try #require(initialReconcileRan)
await ble._test_drainNoiseMessagePipeline()
// Keep one tap installed for the service's whole lifetime. After the
// explicit initial-frame observation and queue fence, start a fresh,
// lock-protected capture epoch so every assertion below measures only
// rollback/convergence output under a saturated concurrent suite.
outbound.removeAll()
// The convergence retry only prepares for reachable peers.
ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice")
let reconciled = SessionReconcileCounter()
ble._test_onPrivateMediaSessionReconciled = reconciled.record
// Park the convergence-recovery callback on its global-queue thread
// before it can enqueue onto messageQueue: the restore handler
// deterministically wins the dispatch race this test exercises.
let recoveryGate = HandshakeRecoveryEnqueueGate()
defer { recoveryGate.release() }
ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() }
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
// Park the traffic the race would lose directly in the pending
// queues the same place live sends land during quarantine so no
@ -935,7 +955,7 @@ struct BLEServiceCoreTests {
// convergence retry's message 1 out of the tap until released.)
let responderReady = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) >= 1 },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
try #require(responderReady)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
@ -943,8 +963,8 @@ struct BLEServiceCoreTests {
// The responder timeout restores the quarantined generation; the
// gate guarantees its handler runs before the convergence retry.
let restoreRan = await TestHelpers.waitUntil(
{ reconciled.count(for: alicePeerID) == 1 },
timeout: TestConstants.longTimeout
{ reconciled.count(for: alicePeerID) == 2 },
timeout: TestConstants.settleTimeout
)
try #require(restoreRan)
#expect(ble.canDeliverSecurely(to: alicePeerID))
@ -976,7 +996,7 @@ struct BLEServiceCoreTests {
== NoiseSecurityConstants.xxInitialMessageSize
}
},
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
try #require(retryStarted)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
@ -1012,7 +1032,7 @@ struct BLEServiceCoreTests {
ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID)
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
@ -1421,6 +1441,10 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
return packets
}
func removeAll() {
lock.lock(); packets.removeAll(); lock.unlock()
}
}
/// Blocks the convergence-recovery callback on its global-queue thread so a
@ -1525,6 +1549,8 @@ private final class PanicIngressObserver: @unchecked Sendable {
}
private func makeService(
noiseHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
@ -1537,6 +1563,7 @@ private func makeService(
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false,
noiseHandshakeTimeout: noiseHandshakeTimeout,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout,
engineScheduler: engineScheduler
)

View File

@ -79,7 +79,15 @@ private final class MockChatNostrContext: ChatNostrContext {
var selectedPrivateChatPeer: PeerID?
var nostrKeyMapping: [PeerID: String] = [:]
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
private(set) var handledPrivateMessages: [
(
payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
timestamp: Date,
source: NostrPrivateMessageSource
)
] = []
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var startedPrivateChats: [PeerID] = []
@ -89,9 +97,16 @@ private final class MockChatNostrContext: ChatNostrContext {
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
messageTimestamp: Date,
source: NostrPrivateMessageSource
) {
handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp))
handledPrivateMessages.append((
payload,
senderPubkey,
convKey,
messageTimestamp,
source
))
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
@ -213,6 +228,7 @@ private final class MockChatNostrContext: ChatNostrContext {
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
var acceptsLegacyNostrDM = true
private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
@ -223,6 +239,10 @@ private final class MockChatNostrContext: ChatNostrContext {
Array(favoriteRelationshipsByNoiseKey.values)
}
func canAcceptLegacyNostrDM(from _: String) -> Bool {
acceptsLegacyNostrDM
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
geohashActivityNotifications.append((geohash, bodyPreview))
}
@ -362,6 +382,7 @@ struct ChatNostrCoordinatorContextTests {
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
#expect(context.handledPrivateMessages.first?.source == .legacy1059)
// The embedded Noise payload survives the round trip intact.
let payload = try #require(context.handledPrivateMessages.first?.payload)
@ -446,6 +467,46 @@ struct ChatNostrCoordinatorContextTests {
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
@Test @MainActor
func processNostrMessage_rejectsLegacyDowngradeBeforeDelivery()
async throws
{
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
context.acceptsLegacyNostrDM = false
let embedded = try #require(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "must not downgrade",
messageID: "legacy-blocked",
senderPeerID: PeerID(str: "aabbccddeeff0011")
)
)
let blockedGiftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
await coordinator.inbound.processNostrMessage(blockedGiftWrap)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs == [blockedGiftWrap.id])
context.acceptsLegacyNostrDM = true
let acceptedGiftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
await coordinator.inbound.processNostrMessage(acceptedGiftWrap)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.source == .legacy1059)
}
@Test @MainActor
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
let context = MockChatNostrContext()
@ -681,4 +742,131 @@ struct GeoPresenceTrackerTests {
)
}
@Test @MainActor
func ndrDelivery_expiredAtFinalMutationDrainsWithoutSideEffects() async throws {
let context = MockChatNostrContext()
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let senderNoiseKey = Data(repeating: 0xA8, count: 32)
context.favoriteRelationshipsByNoiseKey[senderNoiseKey] =
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: sender.npub,
peerNickname: "expired-peer",
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
let senderPeerID = PeerID(str: "0011223344556677")
let embedded = try #require(
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .delivered,
messageID: "expired-ndr",
senderPeerID: senderPeerID
)
)
let unsigned = NostrEvent(
pubkey: sender.publicKeyHex,
createdAt: Date(timeIntervalSince1970: 99),
kind: .dm,
tags: [],
content: embedded
)
var rumor = try unsigned.sign(
with: sender.schnorrSigningKey()
)
rumor.sig = nil
let presence = GeoPresenceTracker(context: context)
let pipeline = NostrInboundPipeline(
context: context,
presence: presence,
now: { Date(timeIntervalSince1970: 100) }
)
var disposition: NdrDeliveryDisposition?
pipeline.handleNdrDecryptedMessage(
NdrDecryptedMessage(
event: rumor,
senderPubkeyHex: sender.publicKeyHex,
outerEventID: String(repeating: "a", count: 64),
expiresAtSeconds: 100
),
completion: { disposition = $0 }
)
let completed = await TestHelpers.waitUntil(
{ disposition != nil },
timeout: TestConstants.settleTimeout
)
#expect(completed)
#expect(disposition == .consumed)
#expect(context.nostrKeyMapping.isEmpty)
#expect(context.handledDelivered.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.handledReadReceipts.isEmpty)
#expect(context.recordedNostrEventIDs == [rumor.id])
}
@Test @MainActor
func ndrDelivery_marksPrivateMessageAsPairwiseOnly() async throws {
let context = MockChatNostrContext()
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0xA9, count: 32)
context.nostrIdentity = recipient
context.favoriteRelationshipsByNoiseKey[noiseKey] =
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: sender.npub,
peerNickname: "pairwise-peer",
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
let embedded = try #require(
NostrEmbeddedBitChat.encodePMForNostr(
content: "pairwise inbound",
messageID: "ndr-inbound-1",
recipientPeerID: PeerID(hexData: noiseKey),
senderPeerID: PeerID(str: "0011223344556677")
)
)
var rumor = try NostrEvent(
pubkey: sender.publicKeyHex,
createdAt: Date(timeIntervalSince1970: 99),
kind: .dm,
tags: [],
content: embedded
).sign(with: sender.schnorrSigningKey())
rumor.sig = nil
let pipeline = NostrInboundPipeline(
context: context,
presence: GeoPresenceTracker(context: context)
)
var disposition: NdrDeliveryDisposition?
pipeline.handleNdrDecryptedMessage(
NdrDecryptedMessage(
event: rumor,
senderPubkeyHex: sender.publicKeyHex,
outerEventID: String(repeating: "c", count: 64),
expiresAtSeconds: nil
),
completion: { disposition = $0 }
)
let completed = await TestHelpers.waitUntil(
{ disposition != nil },
timeout: TestConstants.settleTimeout
)
#expect(completed)
#expect(disposition == .consumed)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.convKey == PeerID(hexData: noiseKey))
#expect(context.handledPrivateMessages.first?.source == .ndr)
}
}

View File

@ -176,6 +176,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
private(set) var accountNostrDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
private(set) var accountNostrReadReceipts: [(messageID: String, peerID: PeerID)] = []
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
private(set) var deliveredMessageIDs: [String] = []
@ -222,6 +224,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
geoReadReceipts.append((messageID, recipientHex))
}
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) {
accountNostrDeliveryAcks.append((messageID, peerID))
}
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) {
accountNostrReadReceipts.append((messageID, peerID))
}
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = []
@ -474,6 +484,8 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"])
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
#expect(context.sentGeoDeliveryAcks == ["geo-1"])
#expect(context.accountNostrDeliveryAcks.isEmpty)
#expect(context.accountNostrReadReceipts.isEmpty)
#expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"])
#expect(context.privateChats[convKey]?.first?.sender == "bob#5678")
#expect(context.unreadPrivateMessages.isEmpty)
@ -491,6 +503,91 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.privateChats[convKey]?.count == 1)
}
@Test @MainActor
func ndrPrivateMessage_sendsOnlyPairwiseDeliveryAndReadAcks() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let stablePeerID = PeerID(
hexData: Data(repeating: 0xA6, count: 32)
)
let senderPubkey = String(repeating: "b", count: 64)
context.selectedPrivateChatPeer = stablePeerID
context.displayNamesByPubkey[senderPubkey] = "bob"
let payloadData = PrivateMessagePacket(
messageID: "ndr-ack-1",
content: "pairwise"
).encode()!
let payload = NoisePayload(
type: .privateMessage,
data: payloadData
)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date(),
source: .ndr
)
#expect(context.accountNostrDeliveryAcks.count == 1)
#expect(context.accountNostrDeliveryAcks.first?.messageID == "ndr-ack-1")
#expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID)
#expect(context.accountNostrReadReceipts.count == 1)
#expect(context.accountNostrReadReceipts.first?.messageID == "ndr-ack-1")
#expect(context.accountNostrReadReceipts.first?.peerID == stablePeerID)
#expect(context.geoDeliveryAcks.isEmpty)
#expect(context.geoReadReceipts.isEmpty)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date(),
source: .ndr
)
#expect(context.accountNostrDeliveryAcks.count == 1)
#expect(context.accountNostrReadReceipts.count == 1)
#expect(context.privateChats[stablePeerID]?.count == 1)
}
@Test @MainActor
func ndrPrivateMessage_notViewingSendsOnlyPairwiseDeliveryAck() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let stablePeerID = PeerID(
hexData: Data(repeating: 0xA5, count: 32)
)
let senderPubkey = String(repeating: "c", count: 64)
context.displayNamesByPubkey[senderPubkey] = "carol"
let payload = NoisePayload(
type: .privateMessage,
data: PrivateMessagePacket(
messageID: "ndr-background-1",
content: "background"
).encode()!
)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date(),
source: .ndr
)
#expect(context.accountNostrDeliveryAcks.count == 1)
#expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID)
#expect(context.accountNostrReadReceipts.isEmpty)
#expect(context.geoDeliveryAcks.isEmpty)
#expect(context.geoReadReceipts.isEmpty)
#expect(context.unreadPrivateMessages == Set([stablePeerID]))
}
@Test @MainActor
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
let context = MockChatPrivateConversationContext()

View File

@ -17,6 +17,7 @@ import BitFoundation
@MainActor
private func makeTestableViewModel(
keychain injectedKeychain: MockKeychain? = nil,
ndrService: NdrNostrService? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
@ -32,6 +33,7 @@ private func makeTestableViewModel(
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
ndrService: ndrService,
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
@ -2228,6 +2230,121 @@ struct ChatViewModelPanicTests {
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedNdrPanicWipeStaysLatchedAcrossViewModelRestart() throws {
enum StorageFailure: Error {
case unavailable
}
let storage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"bitchat-tests-panic-ndr-\(UUID().uuidString)",
isDirectory: true
)
var storageAvailable = true
var recoveryPending = false
var beginCount = 0
var completeCount = 0
let recoveryOperations = PanicRecoveryOperations(
isPending: { recoveryPending },
begin: {
recoveryPending = true
beginCount += 1
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: true
)
},
wipeMedia: { _ in },
complete: {
recoveryPending = false
completeCount += 1
}
)
let markerStore = InMemoryNdrSessionMarkerStore()
let firstNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: {
guard storageAvailable else {
throw StorageFailure.unavailable
}
return storage
},
sessionMarkerStore: markerStore
)
firstNdrService.configureIfNeeded(
identity: try NostrIdentity.generate()
)
#expect(
FileManager.default.fileExists(atPath: storage.path)
)
let (firstViewModel, firstTransport) =
makeTestableViewModel(
ndrService: firstNdrService,
panicRecoveryOperations: recoveryOperations
)
let startsBeforePanic = firstTransport.startServicesCallCount
storageAvailable = false
#expect(!firstViewModel.panicClearAllData())
#expect(recoveryPending)
#expect(completeCount == 0)
#expect(
firstTransport.startServicesCallCount == startsBeforePanic
)
#expect(!firstViewModel.networkActivationAllowed)
#expect(
FileManager.default.fileExists(atPath: storage.path)
)
let secondNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: {
guard storageAvailable else {
throw StorageFailure.unavailable
}
return storage
},
sessionMarkerStore: markerStore
)
let (secondViewModel, secondTransport) =
makeTestableViewModel(
ndrService: secondNdrService,
panicRecoveryOperations: recoveryOperations
)
#expect(recoveryPending)
#expect(beginCount == 2)
#expect(completeCount == 0)
#expect(secondTransport.startServicesCallCount == 0)
#expect(!secondViewModel.networkActivationAllowed)
storageAvailable = true
let thirdNdrService = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: { storage },
sessionMarkerStore: markerStore
)
let (thirdViewModel, thirdTransport) =
makeTestableViewModel(
ndrService: thirdNdrService,
panicRecoveryOperations: recoveryOperations
)
#expect(!recoveryPending)
#expect(beginCount == 3)
#expect(completeCount == 1)
#expect(thirdTransport.startServicesCallCount == 1)
#expect(thirdViewModel.networkActivationAllowed)
#expect(
!FileManager.default.fileExists(atPath: storage.path)
)
}
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()

File diff suppressed because it is too large Load Diff

View File

@ -347,6 +347,319 @@ struct PrivateMediaEndToEndTests {
_ = cancellable
}
@Test
func ndrPublicAnnouncementCannotAuthorizeDarkAuthenticatedPeer() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("ndr-public-only-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
doubleRatchetEnabled: true
)
// Bob models a production-dark build: even if a public announce claims
// the bit, his exact authenticated proof cannot contain it.
let bob = makeService(
baseDirectory: root.appendingPathComponent("bob", isDirectory: true)
)
let publicClaim = try signedAnnounce(
from: bob,
capabilities:
PeerCapabilities.localSupported.union(.doubleRatchet)
)
alice._test_handlePacket(
publicClaim,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let publicClaimObserved = await TestHelpers.waitUntil(
{
alice.peerCapabilities(bob.myPeerID).contains(.doubleRatchet)
},
timeout: TestConstants.longTimeout
)
try #require(publicClaimObserved)
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let authenticated = await TestHelpers.waitUntil(
{
alice.authenticatedPeerTransportState(bob.myPeerID) != nil
},
timeout: TestConstants.longTimeout
)
try #require(authenticated)
await alice._test_drainNoiseMessagePipeline()
let authenticatedState = try #require(
alice.authenticatedPeerTransportState(bob.myPeerID)
)
#expect(!authenticatedState.capabilities.contains(.doubleRatchet))
#expect(alice.peerCapabilities(bob.myPeerID).contains(.doubleRatchet))
#expect(
alice.authenticatedPeerTransportState(bob.myPeerID)
== authenticatedState
)
await alice._test_drainNoiseMessagePipeline()
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: authenticatedState,
completion: admission.record
)
await alice._test_drainNoiseMessagePipeline()
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot() == [false] },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().allSatisfy {
$0.type != MessageType.noiseEncrypted.rawValue
}
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func ndrAuthenticatedCurrentCapabilityAcceptsSend() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("ndr-authenticated-send-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
doubleRatchetEnabled: true
)
let bob = makeService(
baseDirectory: root.appendingPathComponent("bob", isDirectory: true),
doubleRatchetEnabled: true
)
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let proofAccepted = await TestHelpers.waitUntil(
{
alice.authenticatedPeerTransportState(bob.myPeerID)?
.capabilities.contains(.doubleRatchet) == true
},
timeout: TestConstants.longTimeout
)
try #require(proofAccepted)
await alice._test_drainNoiseMessagePipeline()
let authenticatedState = try #require(
alice.authenticatedPeerTransportState(bob.myPeerID)
)
#expect(authenticatedState.noisePublicKey == bob.noiseStaticPublicKeyData())
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: authenticatedState,
completion: admission.record
)
let sent = await TestHelpers.waitUntil(
{
tap.snapshot().contains {
$0.type == MessageType.noiseEncrypted.rawValue
&& PeerID(hexData: $0.recipientID) == bob.myPeerID
}
},
timeout: TestConstants.longTimeout
)
#expect(sent)
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().filter {
$0.type == MessageType.noiseEncrypted.rawValue
}.count == 1
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func ndrExpectedOldAuthenticatedStateRejectsAfterReplacement() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("ndr-stale-generation-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let bobKeychain = MockKeychain()
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
doubleRatchetEnabled: true
)
let bob = makeService(
baseDirectory: root.appendingPathComponent("bob", isDirectory: true),
keychain: bobKeychain,
doubleRatchetEnabled: true
)
// Alice must be the original responder so Bob's restarted message one
// enters the ordinary replacement path instead of the initiator grace
// coalescing path.
try await establishSession(alice: bob, bob: alice)
let oldState = try #require(
alice.authenticatedPeerTransportState(bob.myPeerID)
)
// A second engine with Bob's persisted static identity models Bob
// restarting and completing an ordinary XX replacement generation.
let restartedBob = NoiseEncryptionService(keychain: bobKeychain)
#expect(
PeerID(publicKey: restartedBob.getStaticPublicKeyData())
== bob.myPeerID
)
let first = try restartedBob.initiateHandshake(with: alice.myPeerID)
let second = try #require(
try alice._test_noiseProcessHandshakeMessage(
from: bob.myPeerID,
message: first
)
)
let third = try #require(
try restartedBob.processHandshakeMessage(
from: alice.myPeerID,
message: second
)
)
_ = try alice._test_noiseProcessHandshakeMessage(
from: bob.myPeerID,
message: third
)
await alice._test_drainNoiseMessagePipeline()
let replacementState = AuthenticatedPeerStatePacket(
capabilities: PeerCapabilities.localSupported.union(.doubleRatchet),
signingPublicKey: restartedBob.getSigningPublicKeyData()
)
let replacementPlaintext = try #require(
BLENoisePayloadFactory.authenticatedPeerState(replacementState)
)
let replacementCiphertext = try restartedBob.encrypt(
replacementPlaintext,
for: alice.myPeerID
)
let replacementProof = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: bob.myPeerID.id) ?? Data(),
recipientID: Data(hexString: alice.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: replacementCiphertext,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
alice._test_handlePacket(
replacementProof,
fromPeerID: bob.myPeerID
)
let replacementAccepted = await TestHelpers.waitUntil(
{
guard let current =
alice.authenticatedPeerTransportState(bob.myPeerID)
else {
return false
}
return current.sessionGeneration != oldState.sessionGeneration
&& current.capabilities.contains(.doubleRatchet)
},
timeout: TestConstants.longTimeout
)
try #require(replacementAccepted)
await alice._test_drainNoiseMessagePipeline()
let tap = PacketTap()
let admission = ReceiptCapabilityRecorder()
alice._test_onOutboundPacket = tap.record
alice.sendNdrEvent(
to: bob.myPeerID,
eventJson: #"{"kind":1059}"#,
expectedTransportState: oldState,
completion: admission.record
)
await alice._test_drainNoiseMessagePipeline()
#expect(
await TestHelpers.waitUntil(
{ admission.snapshot() == [false] },
timeout: TestConstants.longTimeout
)
)
#expect(
tap.snapshot().allSatisfy {
$0.type != MessageType.noiseEncrypted.rawValue
}
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func identicalAuthenticatedNdrProofEmitsOneStateUpdatedEvent() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("ndr-proof-idempotence-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
doubleRatchetEnabled: true
)
let bob = makeService(
baseDirectory: root.appendingPathComponent("bob", isDirectory: true),
doubleRatchetEnabled: true
)
let recorder = AuthenticatedTransportStateEventRecorder()
alice.eventDelegate = recorder
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let firstUpdate = await TestHelpers.waitUntil(
{ recorder.count(for: bob.myPeerID) == 1 },
timeout: TestConstants.longTimeout
)
try #require(firstUpdate)
let capabilities =
PeerCapabilities.localSupported.union(.doubleRatchet)
let firstDuplicate = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: capabilities
)
let secondDuplicate = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: capabilities
)
alice._test_handlePacket(firstDuplicate, fromPeerID: bob.myPeerID)
alice._test_handlePacket(secondDuplicate, fromPeerID: bob.myPeerID)
await alice._test_drainNoiseMessagePipeline()
await MainActor.run {}
#expect(recorder.count(for: bob.myPeerID) == 1)
alice.eventDelegate = nil
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() {
let root = FileManager.default.temporaryDirectory
@ -1384,15 +1697,18 @@ struct PrivateMediaEndToEndTests {
private func makeService(
baseDirectory: URL,
identityManager: SecureIdentityStateManagerProtocol? = nil
identityManager: SecureIdentityStateManagerProtocol? = nil,
keychain providedKeychain: MockKeychain? = nil,
doubleRatchetEnabled: Bool = false
) -> BLEService {
let keychain = MockKeychain()
let keychain = providedKeychain ?? MockKeychain()
return BLEService(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: identityManager ?? MockIdentityManager(keychain),
initializeBluetoothManagers: false,
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory)
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory),
doubleRatchetEnabled: doubleRatchetEnabled
)
}
@ -1556,6 +1872,30 @@ private final class PacketTap: @unchecked Sendable {
}
}
private final class AuthenticatedTransportStateEventRecorder:
TransportEventDelegate,
@unchecked Sendable
{
private let lock = NSLock()
private var updatedPeerIDs: [PeerID] = []
@MainActor
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case .authenticatedPeerTransportStateUpdated(let peerID) = event else {
return
}
lock.lock()
updatedPeerIDs.append(peerID)
lock.unlock()
}
func count(for peerID: PeerID) -> Int {
lock.lock()
defer { lock.unlock() }
return updatedPeerIDs.filter { $0 == peerID }.count
}
}
private final class PrivateMediaDeferredSendGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false

View File

@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var simulatedGenericReadError: KeychainReadResult?
var simulatedGenericSaveFailureKeys = Set<String>()
var simulatedGenericDeleteFailureKeys = Set<String>()
var simulatedDeleteAllResult = true
private(set) var deleteAllCallCount = 0
@ -77,6 +79,9 @@ final class MockKeychain: KeychainManagerProtocol {
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
guard !simulatedGenericSaveFailureKeys.contains(key) else {
return
}
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
@ -98,6 +103,9 @@ final class MockKeychain: KeychainManagerProtocol {
}
func delete(key: String, service: String) {
guard !simulatedGenericDeleteFailureKeys.contains(key) else {
return
}
serviceStorage[service]?.removeValue(forKey: key)
}

View File

@ -15,7 +15,8 @@ import BitFoundation
/// Mock Transport implementation for testing ChatViewModel in isolation.
/// Records all method calls and allows test code to verify interactions.
final class MockTransport: Transport, PrivateMediaDeletionPersisting,
MeshFileTransferring, MeshVerifying, MeshCourierTransporting,
MeshFileTransferring, MeshDoubleRatchetTransporting, MeshVerifying,
MeshCourierTransporting,
MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming,
MeshGroupMessaging, MeshBoardBroadcasting {
@ -48,6 +49,13 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting,
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentNdrEvents: [
(
peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState
)
] = []
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
private(set) var startServicesCallCount = 0
private(set) var stopServicesCallCount = 0
@ -69,6 +77,10 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting,
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
var authenticatedPeerTransportStates: [
PeerID: AuthenticatedPeerTransportState
] = [:]
var ndrSendResults: [Bool] = []
var persistDeletedPrivateMediaResult = true
var deferDeletedPrivateMediaPersistence = false
private var pendingDeletedPrivateMediaCompletions: [
@ -135,6 +147,32 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting,
triggeredHandshakes.append(peerID)
}
func authenticatedPeerTransportState(
_ peerID: PeerID
) -> AuthenticatedPeerTransportState? {
authenticatedPeerTransportStates[peerID]
}
func sendNdrEvent(
to peerID: PeerID,
eventJson: String,
expectedTransportState: AuthenticatedPeerTransportState,
completion: @escaping @MainActor (Bool) -> Void
) {
sentNdrEvents.append(
(
peerID: peerID,
eventJson: eventJson,
expectedTransportState: expectedTransportState
)
)
let succeeded =
ndrSendResults.isEmpty ? false : ndrSendResults.removeFirst()
Task { @MainActor in
completion(succeeded)
}
}
func purgeArchivedPublicMessages(from peerID: PeerID) {
purgedArchivePeers.append(peerID)
}

View File

@ -0,0 +1,280 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Nostr identity lifecycle", .serialized)
struct NostrIdentityBridgeLifecycleTests {
private static let identityKey = "nostr-current-identity"
private static let service = "chat.bitchat.nostr"
@Test("A truly absent identity is created durably and reused")
func firstRunCreatesDurableIdentity() throws {
let keychain = MockKeychain()
let bridge = NostrIdentityBridge(keychain: keychain)
let firstRead = try bridge.getCurrentNostrIdentity()
let secondRead = try bridge.getCurrentNostrIdentity()
let created = try #require(firstRead)
let restored = try #require(secondRead)
#expect(restored.publicKeyHex == created.publicKeyHex)
guard case .success(let stored) = keychain.loadWithResult(
key: Self.identityKey,
service: Self.service
) else {
Issue.record("Expected a durable identity after first-run creation")
return
}
#expect(
try JSONDecoder().decode(
NostrIdentity.self,
from: stored
).publicKeyHex == created.publicKeyHex
)
}
@Test("Protected-data read failures never mint a replacement identity")
func protectedDataFailureFailsClosed() {
let keychain = MockKeychain()
keychain.simulatedGenericReadError = .deviceLocked
let bridge = NostrIdentityBridge(keychain: keychain)
#expect(throws: (any Error).self) {
_ = try bridge.getCurrentNostrIdentity()
}
}
@Test("Transient keychain read failures never mint a replacement identity")
func transientReadFailureFailsClosed() {
let keychain = MockKeychain()
keychain.simulatedGenericReadError = .otherError(-1)
let bridge = NostrIdentityBridge(keychain: keychain)
#expect(throws: (any Error).self) {
_ = try bridge.getCurrentNostrIdentity()
}
}
@Test("An undurable first-run identity is never returned")
func saveFailureFailsClosed() {
let keychain = MockKeychain()
keychain.simulatedGenericSaveFailureKeys.insert(Self.identityKey)
let bridge = NostrIdentityBridge(keychain: keychain)
#expect(throws: (any Error).self) {
_ = try bridge.getCurrentNostrIdentity()
}
}
@Test("Concurrent bridge instances return one durable identity")
func concurrentBridgeInstancesReturnSameIdentity() async throws {
let keychain = BlockingIdentityLifecycleKeychain()
let firstBridge = NostrIdentityBridge(keychain: keychain)
let secondBridge = NostrIdentityBridge(keychain: keychain)
let firstTask = Task.detached {
try firstBridge.getCurrentNostrIdentity()
}
guard keychain.waitForFirstSave() else {
keychain.releaseFirstSave()
Issue.record("First identity creation never reached persistence")
return
}
let secondTask = Task.detached {
try secondBridge.getCurrentNostrIdentity()
}
// With instance-local locking the second bridge can create and read a
// different identity while the first save is paused. With the shared
// lifecycle lock it remains outside the keychain until the first
// identity is durable.
_ = keychain.waitForSecondCreation()
keychain.releaseFirstSave()
let first = try #require(try await firstTask.value)
let second = try #require(try await secondTask.value)
#expect(first.publicKeyHex == second.publicKeyHex)
#expect(keychain.identitySaveCount == 1)
}
@Test("Panic clear cannot be overtaken by an in-flight identity save")
func panicClearSerializesWithInFlightCreate() async throws {
let keychain = BlockingIdentityLifecycleKeychain()
let creatingBridge = NostrIdentityBridge(keychain: keychain)
let clearingBridge = NostrIdentityBridge(keychain: keychain)
let createTask = Task.detached {
try creatingBridge.getCurrentNostrIdentity()
}
guard keychain.waitForFirstSave() else {
keychain.releaseFirstSave()
Issue.record("Identity creation never reached persistence")
return
}
let clearTask = Task.detached {
clearingBridge.clearAllAssociations()
}
// Before lifecycle serialization, panic deletion completes while the
// pre-panic save is paused and that save can resurrect the identity.
_ = keychain.waitForDeleteAll()
keychain.releaseFirstSave()
_ = try await createTask.value
await clearTask.value
guard case .itemNotFound = keychain.loadWithResult(
key: Self.identityKey,
service: Self.service
) else {
Issue.record("A pre-panic identity was saved after panic clear")
return
}
}
}
private final class BlockingIdentityLifecycleKeychain:
KeychainManagerProtocol,
@unchecked Sendable
{
private let lock = NSLock()
private let firstSaveEntered = DispatchSemaphore(value: 0)
private let firstSaveRelease = DispatchSemaphore(value: 0)
private let secondCreationRead = DispatchSemaphore(value: 0)
private let deleteAllCompleted = DispatchSemaphore(value: 0)
private var serviceStorage: [String: [String: Data]] = [:]
private var saveCount = 0
private var didSignalSecondCreation = false
var identitySaveCount: Int {
lock.withLock { saveCount }
}
func waitForFirstSave() -> Bool {
firstSaveEntered.wait(timeout: .now() + 1) == .success
}
func waitForSecondCreation() -> Bool {
secondCreationRead.wait(timeout: .now() + 1) == .success
}
func waitForDeleteAll() -> Bool {
deleteAllCompleted.wait(timeout: .now() + 1) == .success
}
func releaseFirstSave() {
firstSaveRelease.signal()
}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
save(key: key, data: keyData, service: "identity", accessible: nil)
return true
}
func getIdentityKey(forKey key: String) -> Data? {
load(key: key, service: "identity")
}
func deleteIdentityKey(forKey key: String) -> Bool {
delete(key: key, service: "identity")
return true
}
func deleteAllKeychainData() -> Bool {
lock.withLock {
serviceStorage.removeAll()
}
return true
}
func secureClear(_ data: inout Data) {
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
getIdentityKey(forKey: "identity_noiseStaticKey") != nil
}
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
guard let data = getIdentityKey(forKey: key) else {
return .itemNotFound
}
return .success(data)
}
func saveIdentityKeyWithResult(
_ keyData: Data,
forKey key: String
) -> KeychainSaveResult {
saveIdentityKey(keyData, forKey: key) ? .success : .otherError(-1)
}
func save(
key: String,
data: Data,
service: String,
accessible _: CFString?
) {
let isFirstSave = lock.withLock {
saveCount += 1
return saveCount == 1
}
if isFirstSave {
firstSaveEntered.signal()
firstSaveRelease.wait()
}
lock.withLock {
serviceStorage[service, default: [:]][key] = data
}
}
func load(key: String, service: String) -> Data? {
guard case .success(let data) = loadWithResult(
key: key,
service: service
) else {
return nil
}
return data
}
func loadWithResult(
key: String,
service: String
) -> KeychainReadResult {
let result: KeychainReadResult
let shouldSignalSecondCreation: Bool
(result, shouldSignalSecondCreation) = lock.withLock {
guard let data = serviceStorage[service]?[key] else {
return (.itemNotFound, false)
}
let shouldSignal = saveCount >= 2 && !didSignalSecondCreation
if shouldSignal {
didSignalSecondCreation = true
}
return (.success(data), shouldSignal)
}
if shouldSignalSecondCreation {
secondCreationRead.signal()
}
return result
}
func delete(key: String, service: String) {
_ = lock.withLock {
serviceStorage[service]?.removeValue(forKey: key)
}
}
func deleteAll(service: String) {
_ = lock.withLock {
serviceStorage.removeValue(forKey: service)
}
deleteAllCompleted.signal()
}
}

View File

@ -118,7 +118,8 @@ struct NostrRelaySettingsTests {
#expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://good.example.com"])
}
@Test func builtInRelaysAreExposedNormalizedForDeduplication() {
@Test @MainActor
func builtInRelaysAreExposedNormalizedForDeduplication() {
// The UI rejects re-adding a built-in by comparing against this set, so
// it has to hold normalized URLs.
let builtIn = NostrRelayManager.builtInRelayURLs

View File

@ -684,7 +684,14 @@ private final class PerfNostrContext: ChatNostrContext {
var selectedPrivateChatPeer: PeerID?
var nostrKeyMapping: [PeerID: String] = [:]
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date,
source: NostrPrivateMessageSource
) {}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
func startPrivateChat(with peerID: PeerID) {}

View File

@ -94,6 +94,7 @@ struct ProtocolContractTests {
// defaults: a core-only transport simply doesn't have them.
#expect(!(probe as AnyObject is MeshFileTransferring))
#expect(!(probe as AnyObject is MeshDiagnosing))
#expect(!(probe as AnyObject is MeshDoubleRatchetTransporting))
#expect(probe.peerCapabilities(peerID).isEmpty)
// Secure delivery defaults to prompt delivery (itself defaulting to
// reachability) for transports without a forgeable link layer.

View File

@ -16,6 +16,7 @@ struct BLENoisePacketHandlerTests {
var decryptResult: Result<Data, Error> = .success(Data())
var currentDate = Date(timeIntervalSince1970: 1_000)
var transportGenerationReady = false
var authorizeNoisePayload = true
var forcedServiceDecryptError: Error?
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
@ -26,6 +27,7 @@ struct BLENoisePacketHandlerTests {
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
var clearedSessions: [PeerID] = []
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
var authorizationChecks: [(peerID: PeerID, type: NoisePayloadType, generation: UUID)] = []
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
/// Ordered side-effect log to assert recovery sequencing.
var events: [String] = []
@ -84,7 +86,11 @@ struct BLENoisePacketHandlerTests {
handleAuthenticatedPeerState: { peerID, payload, generation in
recorder.authenticatedPeerStates.append((peerID, payload, generation))
},
deliverNoisePayload: { peerID, type, payload, timestamp in
authorizeNoisePayload: { peerID, type, generation in
recorder.authorizationChecks.append((peerID, type, generation))
return recorder.authorizeNoisePayload
},
deliverNoisePayload: { peerID, type, payload, timestamp, _ in
recorder.deliveries.append((peerID, type, payload, timestamp))
}
)
@ -156,8 +162,14 @@ struct BLENoisePacketHandlerTests {
(peerID, payload, generation)
)
},
authorizeNoisePayload: { peerID, type, generation in
recorder.authorizationChecks.append(
(peerID, type, generation)
)
return recorder.authorizeNoisePayload
},
deliverNoisePayload: {
peerID, type, payload, timestamp in
peerID, type, payload, timestamp, _ in
recorder.deliveries.append(
(peerID, type, payload, timestamp)
)
@ -393,6 +405,30 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ndrPayloadWithoutProofForDecryptingGenerationIsDropped() {
let recorder = Recorder()
recorder.authorizeNoisePayload = false
recorder.decryptResult = .success(
Data([NoisePayloadType.ndrEvent.rawValue]) + Data("invite".utf8)
)
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.authorizationChecks.count == 1)
#expect(recorder.authorizationChecks.first?.peerID == remotePeerID)
#expect(recorder.authorizationChecks.first?.type == .ndrEvent)
#expect(
recorder.authorizationChecks.first?.generation
== recorder.sessionGeneration
)
}
@Test
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
let recorder = Recorder()

View File

@ -114,5 +114,6 @@ struct BLENoiseReconnectPolicyTests {
#expect(
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
)
#expect(!PeerCapabilities.localSupported.contains(.doubleRatchet))
}
}

View File

@ -5,6 +5,24 @@ import Testing
@Suite("BLE outbound fragment planner tests")
struct BLEOutboundFragmentPlannerTests {
@Test("exact-generation admission promotes fragments to FIFO-high priority")
func exactGenerationAdmissionUsesHighPriority() {
let ordinary = BLEOutboundWritePriority.fragment(totalFragments: 4)
#expect(
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority: ordinary,
requiresExactGeneration: true
) == .high
)
#expect(
BLEAuthenticatedTransportAdmission.writePriority(
ordinaryPriority: ordinary,
requiresExactGeneration: false
) == ordinary
)
}
@Test("planner splits packets and preserves reassembled payload")
func plannerSplitsAndReassemblesPacket() throws {
let packet = makePacket(payload: makePayload(count: 384))
@ -60,6 +78,34 @@ struct BLEOutboundFragmentPlannerTests {
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) })
}
@Test("link-derived chunks keep every directed fragment within the link limit")
func linkDerivedChunksFitTheLinkLimit() throws {
let linkLimit = 512
let directedPeer = PeerID(str: "8877665544332211")
let packet = makePacket(payload: makePayload(count: 1_024))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: BLEOutboundPacketPolicy.fragmentChunkSize(
forLinkLimit: linkLimit
),
directedPeer: directedPeer,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: linkLimit,
fragmentID: Data(repeating: 0xB3, count: 8)
))
let encodedFragments = try plan.fragmentPackets.map {
try #require($0.toBinaryData(padding: false))
}
#expect(encodedFragments.allSatisfy { $0.count <= linkLimit })
}
@Test("route-aware fragments use version two and route-sized chunking")
func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws {
let route = [
@ -89,6 +135,43 @@ struct BLEOutboundFragmentPlannerTests {
#expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR })
}
@Test("link-derived chunks cannot override route-safe fragment sizing")
func linkDerivedChunksRespectRouteOverhead() throws {
let linkLimit = 512
let route = [
Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]),
Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27])
]
let directedPeer = PeerID(str: "8877665544332211")
let packet = makePacket(
payload: makePayload(count: 1_024),
route: route
)
let requestedChunk = BLEOutboundPacketPolicy.fragmentChunkSize(
forLinkLimit: linkLimit
)
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: requestedChunk,
directedPeer: directedPeer,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: linkLimit,
fragmentID: Data(repeating: 0xC4, count: 8)
))
let encodedFragments = try plan.fragmentPackets.map {
try #require($0.toBinaryData(padding: false))
}
#expect(plan.chunkSize < requestedChunk)
#expect(encodedFragments.allSatisfy { $0.count <= linkLimit })
}
@Test("invalid fragment IDs do not produce a plan")
func invalidFragmentIDReturnsNil() {
let packet = makePacket(payload: makePayload(count: 128))
@ -160,6 +243,68 @@ struct BLEOutboundFragmentPlannerTests {
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
@Test("Noise rotation stops exact-generation fragment admission")
func noiseRotationStopsStrictNdrFragmentTrain() {
let peer = PeerID(str: "8877665544332211")
let expected = AuthenticatedPeerTransportState(
capabilities: [.doubleRatchet],
sessionGeneration: UUID(),
noisePublicKey: Data(repeating: 0x42, count: 32)
)
let rotated = AuthenticatedPeerTransportState(
capabilities: [.doubleRatchet],
sessionGeneration: UUID(),
noisePublicKey: expected.noisePublicKey
)
let request = BLEOutboundFragmentTransferRequest(
packet: BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677")
?? Data(),
recipientID: Data(hexString: peer.id),
timestamp: 0x0102030405,
payload: Data(repeating: 0x55, count: 384),
signature: nil,
ttl: 3
),
pad: false,
maxChunk: 128,
directedPeer: peer,
transferId: nil,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true,
requiredAuthenticatedTransportState: expected
)
var current: AuthenticatedPeerTransportState? = expected
var admitted: [Int] = []
let fullyAdmitted = BLEStrictFragmentAdmission.admitAll(
[0, 1, 2]
) { index in
guard let carried =
request.requiredAuthenticatedTransportState,
BLEAuthenticatedTransportAdmission.isCurrent(
expected: carried,
current: current
)
else {
return false
}
admitted.append(index)
current = rotated
return true
}
#expect(!fullyAdmitted)
#expect(admitted == [0])
#expect(
!BLEAuthenticatedTransportAdmission.isCurrent(
expected: expected,
current: rotated
)
)
}
private func makePacket(
payload: Data,
route: [Data]? = nil,

View File

@ -95,6 +95,46 @@ struct BLEOutboundWriteBufferTests {
#expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01])
}
@Test
func admittedStrictFramesSurviveLaterHighPriorityTraffic() {
var buffer = BLEOutboundWriteBuffer()
let peerID = "peer-1"
let first = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x01, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
let second = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x02, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
let laterNormal = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x03, count: 8),
for: peerID,
priority: .fragment(totalFragments: 2),
capBytes: 16
)
let laterHigh = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x04, count: 8),
for: peerID,
priority: .high,
capBytes: 16
)
#expect(first.accepted)
#expect(second.accepted)
#expect(!laterNormal.accepted)
#expect(!laterHigh.accepted)
#expect(
buffer.takeAll(for: peerID).compactMap(\.data.first)
== [0x01, 0x02]
)
}
@Test
func disconnectDiscardRemovesOnlyThatPeripheralQueue() {
var buffer = BLEOutboundWriteBuffer()

View File

@ -6,6 +6,10 @@ import BitFoundation
final class FavoritesPersistenceServiceTests: XCTestCase {
private let storageKey = "chat.bitchat.favorites"
private let serviceKey = "chat.bitchat.favorites"
private let rebindJournalKey =
"chat.bitchat.favorites.ndr-rebind-journal"
private let ndrRequiredKey =
"chat.bitchat.favorites.ndr-required-noise-keys"
func test_addFavorite_persistsAndPostsNotification() throws {
let keychain = MockKeychain()
@ -110,4 +114,530 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)
XCTAssertEqual(decoded.count, 1)
}
func test_preNdrIdentityRebindDoesNotPinOrRetire() {
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x41, count: 32)
let owner = UUID()
var commitCalled = false
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Pre-NDR"
)
service.installNostrIdentityRebindAuthorization(
owner: owner,
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in
commitCalled = true
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertFalse(commitCalled)
XCTAssertFalse(service.isNdrRequired(for: peerKey))
XCTAssertFalse(
service.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
}
func test_postSessionRebindJournalsBeforeRetireAndPreservesPin()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x42, count: 32)
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Pinned"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
var commitCalled = false
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, old, new in
commitCalled = true
XCTAssertEqual(old, "old")
XCTAssertEqual(new, "new")
XCTAssertNotNil(
keychain.load(
key: self.rebindJournalKey,
service: self.serviceKey
)
)
let stored = try? JSONDecoder().decode(
[FavoritesPersistenceService.FavoriteRelationship].self,
from: keychain.load(
key: self.storageKey,
service: self.serviceKey
) ?? Data()
)
XCTAssertEqual(
stored?.first?.peerNostrPublicKey,
"old"
)
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertTrue(commitCalled)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertTrue(service.isNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_failedFavoriteCommitKeepsJournalAndRecoversOnRestart() {
let keychain = MockKeychain()
let peerKey = Data(repeating: 0x43, count: 32)
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: "old",
peerNickname: "Recoverable"
)
XCTAssertTrue(first.markNdrRequired(for: peerKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: "new"
)
XCTAssertEqual(
first.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"old"
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
XCTAssertTrue(
first.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
let restarted = FavoritesPersistenceService(keychain: keychain)
XCTAssertFalse(
restarted.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertTrue(
restarted.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
"new"
)
XCTAssertTrue(restarted.isNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_recoveryTreatsEquivalentHexAndNpubJournalIdentityAsSame()
throws
{
let keychain = MockKeychain()
let peerKey = Data(repeating: 0x4a, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Equivalent recovery"
)
XCTAssertTrue(first.markNdrRequired(for: peerKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
let storedData = try XCTUnwrap(
keychain.load(key: storageKey, service: serviceKey)
)
let storedRelationships = try JSONDecoder().decode(
[FavoritesPersistenceService.FavoriteRelationship].self,
from: storedData
)
let stored = try XCTUnwrap(storedRelationships.first)
let equivalentOld = FavoritesPersistenceService
.FavoriteRelationship(
peerNoisePublicKey: stored.peerNoisePublicKey,
peerNostrPublicKey:
oldIdentity.publicKeyHex.uppercased(),
peerNickname: stored.peerNickname,
isFavorite: stored.isFavorite,
theyFavoritedUs: stored.theyFavoritedUs,
favoritedAt: stored.favoritedAt,
lastUpdated: stored.lastUpdated
)
keychain.save(
key: storageKey,
data: try JSONEncoder().encode([equivalentOld]),
service: serviceKey,
accessible: nil
)
let restarted = FavoritesPersistenceService(keychain: keychain)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
targetIdentity.npub
)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_equivalentHexAndNpubIdentityUpdateIsANondestructiveNoop()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x44, count: 32)
let identity = try NostrIdentity.generate()
let storedHex = identity.publicKeyHex.uppercased()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: storedHex,
peerNickname: "Equivalent"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
var authorizeCalled = false
var commitCalled = false
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in
authorizeCalled = true
return true
},
commit: { _, _, _ in
commitCalled = true
return true
}
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: identity.npub
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
storedHex
)
XCTAssertFalse(authorizeCalled)
XCTAssertFalse(commitCalled)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_pendingJournalReservesTargetAcrossFavoritesAndRestart()
throws
{
let keychain = MockKeychain()
let firstNoiseKey = Data(repeating: 0x45, count: 32)
let secondNoiseKey = Data(repeating: 0x46, count: 32)
let firstOld = try NostrIdentity.generate()
let secondOld = try NostrIdentity.generate()
let target = try NostrIdentity.generate()
let first = FavoritesPersistenceService(keychain: keychain)
first.addFavorite(
peerNoisePublicKey: firstNoiseKey,
peerNostrPublicKey: firstOld.npub,
peerNickname: "First"
)
first.addFavorite(
peerNoisePublicKey: secondNoiseKey,
peerNostrPublicKey: secondOld.npub,
peerNickname: "Second"
)
XCTAssertTrue(first.markNdrRequired(for: firstNoiseKey))
first.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: firstNoiseKey,
favorited: true,
peerNostrPublicKey: target.npub
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
keychain.simulatedGenericSaveFailureKeys.remove(storageKey)
first.updatePeerFavoritedUs(
peerNoisePublicKey: secondNoiseKey,
favorited: true,
peerNostrPublicKey: target.npub
)
XCTAssertEqual(
first.getFavoriteStatus(for: secondNoiseKey)?
.peerNostrPublicKey,
secondOld.npub
)
let restarted = FavoritesPersistenceService(keychain: keychain)
restarted.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: firstNoiseKey)?
.peerNostrPublicKey,
target.npub
)
XCTAssertEqual(
restarted.getFavoriteStatus(for: secondNoiseKey)?
.peerNostrPublicKey,
secondOld.npub
)
XCTAssertNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
}
func test_journalClearFailureAllowsCommittedTargetButBlocksFallback()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x47, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Clear failure"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericDeleteFailureKeys.insert(
rebindJournalKey
)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
XCTAssertEqual(
service.getFavoriteStatus(for: peerKey)?
.peerNostrPublicKey,
targetIdentity.npub
)
XCTAssertNotNil(
keychain.load(
key: rebindJournalKey,
service: serviceKey
)
)
XCTAssertTrue(
service.canUseNdrBinding(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: targetIdentity.npub
)
)
XCTAssertTrue(
service.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertTrue(service.canActivateDoubleRatchetRelay)
XCTAssertTrue(
service.isNdrFallbackBlocked(
for: PeerID(publicKey: peerKey)
)
)
}
func test_pinWriteFailureFailsClosedForBindingsAndLegacyInbound()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x48, count: 32)
let identity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Pin failure"
)
keychain.simulatedGenericSaveFailureKeys.insert(ndrRequiredKey)
XCTAssertFalse(service.markNdrRequired(for: peerKey))
XCTAssertNil(
keychain.load(
key: ndrRequiredKey,
service: serviceKey
)
)
XCTAssertTrue(service.isNdrRequired(for: peerKey))
XCTAssertFalse(
service.canUseNdrBinding(
for: PeerID(publicKey: peerKey)
)
)
XCTAssertFalse(service.canActivateDoubleRatchetRelay)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: identity.publicKeyHex
)
)
}
func test_legacyInboundPolicyRejectsPinnedAndJournalIdentities()
throws
{
let keychain = MockKeychain()
let service = FavoritesPersistenceService(keychain: keychain)
let peerKey = Data(repeating: 0x49, count: 32)
let oldIdentity = try NostrIdentity.generate()
let targetIdentity = try NostrIdentity.generate()
let unrelatedIdentity = try NostrIdentity.generate()
service.addFavorite(
peerNoisePublicKey: peerKey,
peerNostrPublicKey: oldIdentity.npub,
peerNickname: "Inbound"
)
XCTAssertTrue(service.markNdrRequired(for: peerKey))
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: oldIdentity.publicKeyHex
)
)
XCTAssertTrue(
service.canAcceptLegacyNostrDM(
from: unrelatedIdentity.publicKeyHex
)
)
service.installNostrIdentityRebindAuthorization(
owner: UUID(),
required: true,
authorize: { _, _, _ in true },
commit: { _, _, _ in true }
)
keychain.simulatedGenericSaveFailureKeys.insert(storageKey)
service.updatePeerFavoritedUs(
peerNoisePublicKey: peerKey,
favorited: true,
peerNostrPublicKey: targetIdentity.npub
)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: oldIdentity.publicKeyHex
)
)
XCTAssertFalse(
service.canAcceptLegacyNostrDM(
from: targetIdentity.publicKeyHex
)
)
}
}

View File

@ -715,8 +715,7 @@ struct NoiseEncryptionServiceTests {
// setup handshake below, where bob is the responder. At 0.06 a
// preempted runner could fire it mid-setup, tear down the half-open
// responder, and make message 3 be answered as a fresh initiation.
ordinaryResponderHandshakeTimeout: 1.0,
ordinaryReconnectRollbackCooldown: 0.3
ordinaryResponderHandshakeTimeout: 1.0
)
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())

View File

@ -151,6 +151,117 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(connected)
}
func test_subscribe_offlineRegistersReplayIntentAndFlushesWhenOnline() async {
let relayURL = "wss://offline-subscribe.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: false
)
let registered = context.manager.subscribe(
filter: makeFilter(),
id: "offline-sub",
relayUrls: [relayURL],
handler: { _ in }
)
XCTAssertTrue(
registered,
"offline success means the replayable request was registered"
)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let flushed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.contains {
$0.contains("offline-sub")
} == true
}
XCTAssertTrue(flushed)
}
func test_ndrHandshakeWhileActivationBlockedRegistersAfterConnectivityWake()
throws
{
let context = makeContext(
permission: .authorized,
activationAllowed: false
)
let localIdentity = try NostrIdentity.generate()
let remoteIdentity = try NostrIdentity.generate()
let localStorage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-activation-local-\(UUID().uuidString)",
isDirectory: true
)
let remoteStorage = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-activation-remote-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(
at: localStorage,
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: remoteStorage,
withIntermediateDirectories: true
)
defer {
try? FileManager.default.removeItem(at: localStorage)
try? FileManager.default.removeItem(at: remoteStorage)
}
var scheduledNdrRetries: [@MainActor () -> Void] = []
let service = NdrNostrService(
relayManager: context.manager,
rolloutEnabled: true,
storageDirectoryProvider: { localStorage },
retryScheduler: { _, operation in
scheduledNdrRetries.append(operation)
}
)
let remote = NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: { remoteStorage }
)
service.configureIfNeeded(identity: localIdentity)
remote.configureIfNeeded(identity: remoteIdentity)
let responseActions = service.processOutOfBandEventJson(
try XCTUnwrap(remote.currentInviteEventJson()),
expectedPeerPubkeyHex: remoteIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
XCTAssertFalse(responseActions.isEmpty)
for action in responseActions {
service.completeOutOfBandAction(action, succeeded: true)
}
while !scheduledNdrRetries.isEmpty {
scheduledNdrRetries.removeFirst()()
}
XCTAssertEqual(
context.manager.debugSubscriptionRequestCount,
0,
"activation policy must reject registration, not falsely ack it"
)
context.activationAllowed.value = true
service.retryRelayActions()
XCTAssertGreaterThan(
context.manager.debugSubscriptionRequestCount,
0,
"the connectivity wake must register the durable native intent"
)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@ -469,6 +580,48 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(results, [true])
}
func test_sendEventImmediately_duplicateOKCountsAsDurableAcceptanceOnlyForExactPrefix() async throws {
let relay = "wss://confirmed-duplicate.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relay])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relay })?
.isConnected == true
}
XCTAssertTrue(connected)
let duplicate = try makeSignedEvent(content: "duplicate")
var results: [Bool] = []
context.manager.sendEventImmediately(
duplicate,
to: [relay]
) { results.append($0) }
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
eventID: duplicate.id,
success: false,
reason: "duplicate: already stored"
)
let duplicateSettled = await waitUntil { results.count == 1 }
XCTAssertTrue(duplicateSettled)
XCTAssertEqual(results, [true])
let notMachineReadable = try makeSignedEvent(
content: "not an exact duplicate prefix"
)
context.manager.sendEventImmediately(
notMachineReadable,
to: [relay]
) { results.append($0) }
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
eventID: notMachineReadable.id,
success: false,
reason: " duplicate: leading whitespace"
)
let rejectionSettled = await waitUntil { results.count == 2 }
XCTAssertTrue(rejectionSettled)
XCTAssertEqual(results, [true, false])
}
func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws {
let relay = "wss://confirmed-timeout.example"
let context = makeContext(permission: .denied)
@ -645,6 +798,195 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
}
func test_subscribe_changedActiveRequestReplacesSameID() async {
let relayURL = "wss://rotating-subscribe.example"
let context = makeContext(permission: .denied)
let initialFilter = makeFilter()
context.manager.subscribe(
filter: initialFilter,
id: "rotating-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let firstSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 1
}
XCTAssertTrue(firstSent)
var rotatedFilter = initialFilter
rotatedFilter.authors = [String(repeating: "a", count: 64)]
context.manager.subscribe(
filter: rotatedFilter,
id: "rotating-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let replacementSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 2
}
XCTAssertTrue(
replacementSent,
"NIP-01 replaces a live subscription atomically when a new REQ reuses its ID"
)
let messages = context.sessionFactory
.latestConnection(for: relayURL)?.sentStrings ?? []
XCTAssertTrue(messages.allSatisfy { $0.contains("\"REQ\"") })
XCTAssertNotEqual(messages.first, messages.last)
let replacementCommitted = await waitUntil {
context.manager.debugPendingSubscriptionCount(
for: relayURL
) == 0
}
XCTAssertTrue(replacementCommitted)
}
func test_subscribe_staleCompletionCannotRegressReplacement() async {
let relayURL = "wss://subscription-completion-order.example"
let context = makeContext(permission: .denied)
context.sessionFactory.deferSendCompletions = true
let initialFilter = makeFilter()
context.manager.subscribe(
filter: initialFilter,
id: "ordered-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let firstSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 1
}
XCTAssertTrue(firstSent)
var replacementFilter = initialFilter
replacementFilter.authors = [String(repeating: "b", count: 64)]
context.manager.subscribe(
filter: replacementFilter,
id: "ordered-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let replacementSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 2
}
XCTAssertTrue(replacementSent)
let connection = context.sessionFactory.latestConnection(
for: relayURL
)
connection?.flushDeferredSendCompletion(at: 1)
let replacementCommitted = await waitUntil {
context.manager.debugPendingSubscriptionCount(
for: relayURL
) == 0
}
XCTAssertTrue(replacementCommitted)
// The first REQ completes last. It must not restore the old logical
// generation or make the same replacement look absent.
connection?.flushDeferredSendCompletion(at: 0)
try? await Task.sleep(nanoseconds: 20_000_000)
context.manager.subscribe(
filter: replacementFilter,
id: "ordered-sub",
relayUrls: [relayURL],
handler: { _ in }
)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(connection?.sentStrings.count, 2)
XCTAssertEqual(
context.manager.debugPendingSubscriptionCount(for: relayURL),
0
)
}
func test_subscribe_reconnectReplaysOnlyLatestReplacement() async {
let relayURL = "wss://subscription-reconnect.example"
let context = makeContext(permission: .denied)
let initialFilter = makeFilter()
context.manager.subscribe(
filter: initialFilter,
id: "reconnecting-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let oldConnection = context.sessionFactory.latestConnection(
for: relayURL
)
oldConnection?.deferSendCompletions = true
let initialSent = await waitUntil {
oldConnection?.sentStrings.count == 1
}
XCTAssertTrue(initialSent)
var replacementFilter = initialFilter
replacementFilter.authors = [String(repeating: "c", count: 64)]
context.manager.subscribe(
filter: replacementFilter,
id: "reconnecting-sub",
relayUrls: [relayURL],
handler: { _ in }
)
let replacementSent = await waitUntil {
oldConnection?.sentStrings.count == 2
}
XCTAssertTrue(replacementSent)
oldConnection?.fail(
error: NSError(
domain: NSURLErrorDomain,
code: NSURLErrorNetworkConnectionLost
)
)
let retryScheduled = await waitUntil {
!context.scheduler.scheduled.isEmpty
}
XCTAssertTrue(retryScheduled)
context.scheduler.runNext()
let replayed = await waitUntil {
let connections =
context.sessionFactory.connectionsByURL[relayURL] ?? []
return connections.count == 2
&& connections.last?.sentStrings.count == 1
}
XCTAssertTrue(replayed)
let connections =
context.sessionFactory.connectionsByURL[relayURL] ?? []
XCTAssertEqual(
connections.last?.sentStrings.first,
oldConnection?.sentStrings.last
)
XCTAssertNotEqual(
oldConnection?.sentStrings.first,
oldConnection?.sentStrings.last
)
oldConnection?.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
context.manager.subscribe(
filter: replacementFilter,
id: "reconnecting-sub",
relayUrls: [relayURL],
handler: { _ in }
)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(connections.last?.sentStrings.count, 1)
XCTAssertEqual(
context.manager.debugPendingSubscriptionCount(for: relayURL),
0
)
}
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
let relayURL = "wss://tor-subscribe.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@ -1045,6 +1387,165 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(counted)
}
func test_receiveEvent_withoutHandlerDoesNotPoisonFutureSubscriptionReplay() async throws {
let relayURL = "wss://future-subscription.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "future subscription")
var barrierEOSECount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "barrier",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { barrierEOSECount += 1 }
)
let barrierSubscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 1
}
XCTAssertTrue(barrierSubscribed)
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
try connection.emitEventMessage(
subscriptionID: "future",
event: event
)
// The relay pipeline is serial. Observing this EOSE proves the
// unsolicited event ahead of it has finished verification and its
// delivery-side bookkeeping before the real subscription is created.
try connection.emitEOSE(subscriptionID: "barrier")
let unsolicitedSettled = await waitUntil {
barrierEOSECount == 1
&& context.manager.relays.first(where: {
$0.url == relayURL
})?.messagesReceived == 1
}
XCTAssertTrue(unsolicitedSettled)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "future",
relayUrls: [relayURL]
) { replayed in
receivedIDs.append(replayed.id)
}
let futureSubscribed = await waitUntil {
connection.sentStrings.count == 2
}
XCTAssertTrue(futureSubscribed)
try connection.emitEventMessage(
subscriptionID: "future",
event: event
)
let replaySettled = await waitUntil {
receivedIDs == [event.id]
|| context.manager
.debugDuplicateInboundEventDropCount(
forSubscriptionID: "future"
) == 1
}
XCTAssertTrue(replaySettled)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(
context.manager.debugDuplicateInboundEventDropCount(
forSubscriptionID: "future"
),
0
)
}
func test_receiveEvent_fromRetiredSubscriptionCannotReachReplacement() async throws {
let relayURL = "wss://retired-subscription.example"
let verifier = ControllableEventSignatureVerifier()
let context = makeContext(
permission: .denied,
verifyEventSignature: { verifier.verify($0) }
)
let event = try makeSignedEvent(content: "retired generation")
var retiredReceivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "replaceable",
relayUrls: [relayURL]
) { received in
retiredReceivedIDs.append(received.id)
}
let initialSubscriptionSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?
.sentStrings.count == 1
}
XCTAssertTrue(initialSubscriptionSent)
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
verifier.blockNextVerification()
try connection.emitEventMessage(
subscriptionID: "replaceable",
event: event
)
let verificationBlocked = await waitUntil {
verifier.isVerificationBlocked
}
guard verificationBlocked else {
verifier.releaseBlockedVerification()
XCTFail("Event never reached signature verification")
return
}
context.manager.unsubscribe(id: "replaceable")
var replacementReceivedIDs: [String] = []
var replacementEOSECount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "replaceable",
relayUrls: [relayURL],
handler: { received in
replacementReceivedIDs.append(received.id)
},
onEOSE: { replacementEOSECount += 1 }
)
let replacementSubscriptionSent = await waitUntil {
connection.sentStrings.count == 3
}
XCTAssertTrue(replacementSubscriptionSent)
// The EOSE is queued behind the blocked event on the same serial relay
// pipeline, so its callback proves the stale event's delivery phase
// completed before assertions or replay.
try connection.emitEOSE(subscriptionID: "replaceable")
verifier.releaseBlockedVerification()
let staleEventSettled = await waitUntil {
replacementEOSECount == 1
}
XCTAssertTrue(staleEventSettled)
XCTAssertTrue(retiredReceivedIDs.isEmpty)
XCTAssertTrue(
replacementReceivedIDs.isEmpty,
"An event admitted by the retired subscription reached its replacement"
)
try connection.emitEventMessage(
subscriptionID: "replaceable",
event: event
)
let replaySettled = await waitUntil {
replacementReceivedIDs == [event.id]
|| context.manager
.debugDuplicateInboundEventDropCount(
forSubscriptionID: "replaceable"
) == 1
}
XCTAssertTrue(replaySettled)
XCTAssertEqual(replacementReceivedIDs, [event.id])
}
func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws {
let relayURL = "wss://parser.example"
let context = makeContext(permission: .denied)
@ -1837,6 +2338,9 @@ final class NostrRelayManagerTests: XCTestCase {
torIsForeground: Bool = true,
notificationCenter: NotificationCenter = NotificationCenter(),
customRelays: MutableRelayList = MutableRelayList(urls: []),
verifyEventSignature: @escaping @Sendable (NostrEvent) -> Bool = {
$0.isValidSignature()
},
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
) -> RelayManagerTestContext {
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
@ -1860,6 +2364,7 @@ final class NostrRelayManagerTests: XCTestCase {
torIsForeground: { torForeground.value },
awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory },
verifyEventSignature: verifyEventSignature,
scheduleAfter: { delay, action in
scheduler.schedule(delay: delay, action: action)
},
@ -1921,6 +2426,45 @@ final class NostrRelayManagerTests: XCTestCase {
}
}
private final class ControllableEventSignatureVerifier: @unchecked Sendable {
private let lock = NSLock()
private let verificationRelease = DispatchSemaphore(value: 0)
private var shouldBlockNext = false
private var blocked = false
var isVerificationBlocked: Bool {
lock.withLock { blocked }
}
func blockNextVerification() {
lock.withLock {
shouldBlockNext = true
}
}
func releaseBlockedVerification() {
verificationRelease.signal()
}
func verify(_ event: NostrEvent) -> Bool {
let shouldBlock = lock.withLock {
guard shouldBlockNext else { return false }
shouldBlockNext = false
return true
}
if shouldBlock {
lock.withLock {
blocked = true
}
verificationRelease.wait()
lock.withLock {
blocked = false
}
}
return event.isValidSignature()
}
}
@MainActor
private struct RelayManagerTestContext {
let manager: NostrRelayManager
@ -2019,6 +2563,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
var pingErrorByURL: [String: Error?] = [:]
var sendErrorByURL: [String: Error?] = [:]
var deferSendCompletions = false
var allConnections: [MockRelayConnection] {
connectionsByURL.values.flatMap { $0 }
@ -2031,6 +2576,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
pingError: pingErrorByURL[url.absoluteString] ?? nil,
sendError: sendErrorByURL[url.absoluteString] ?? nil
)
connection.deferSendCompletions = deferSendCompletions
connectionsByURL[url.absoluteString, default: []].append(connection)
return connection
}
@ -2091,6 +2637,13 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
}
func flushDeferredSendCompletion(at index: Int) {
guard deferredSendCompletions.indices.contains(index) else {
return
}
deferredSendCompletions.remove(at: index)(sendError)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
if !pendingResults.isEmpty {
completionHandler(pendingResults.removeFirst())

View File

@ -21,6 +21,7 @@ struct NostrTransportTests {
func reachabilityCacheWarmsFromFavorites() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "reachability-cache")
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
@ -35,6 +36,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
loadFavorites: { favorites },
favoriteStatusForNoiseKey: { favorites[$0] },
@ -55,6 +57,7 @@ struct NostrTransportTests {
func favoriteStatusNotificationRefreshesReachability() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "favorite-refresh")
let recipient = try NostrIdentity.generate()
let noiseKey = Data((32..<64).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey).toShort()
@ -64,6 +67,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
notificationCenter: notificationCenter,
loadFavorites: { favorites },
@ -136,6 +140,7 @@ struct NostrTransportTests {
func sendPrivateMessageResolvesShortPeerID() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "private-message")
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((64..<96).map(UInt8.init))
@ -149,6 +154,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { _ in nil },
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
@ -176,11 +182,487 @@ struct NostrTransportTests {
#expect(probe.pendingGiftWrapIDs.isEmpty)
}
@Test("Private message prefers NDR when a session already exists")
@MainActor
func sendPrivateMessagePrefersNdrWhenSessionExists() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderStorage = try makeTempDir(label: "transport-ndr-sender")
let recipientStorage = try makeTempDir(label: "transport-ndr-recipient")
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
let noiseKey = Data((64..<96).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Carol"
)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let transportUsed = try transport.sendPrivateMessageAndReturnTransport(
"hello via ndr",
to: fullPeerID,
recipientNickname: "Carol",
messageID: "pm-ndr"
)
#expect(transportUsed == .ndr)
#expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 }))
}
@Test("Disappearing-message expiry reaches the pairwise delivery")
@MainActor
func disappearingMessageForwardsAbsoluteExpiryToNdr() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderStorage = try makeTempDir(
label: "transport-expiry-sender"
)
let recipientStorage = try makeTempDir(
label: "transport-expiry-recipient"
)
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
senderRelay.resetSentEvents()
let noiseKey = Data((72..<104).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Expires"
)
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let expiration: UInt64 = 4_000_000_000
let used = try transport.sendPrivateMessageAndReturnTransport(
"vanish later",
to: peerID,
recipientNickname: "Expires",
messageID: "pm-expiring",
expiresAtSeconds: expiration
)
let outbound = try #require(
senderRelay.sentEvents.first { $0.kind == 1060 }
)
var deliveredExpiration: UInt64?
recipientNdr.onDecryptedMessage = { message, completion in
deliveredExpiration = message.expiresAtSeconds
completion(.consumed)
}
recipientNdr.processInboundRelayEvent(outbound)
#expect(used == .ndr)
#expect(deliveredExpiration == expiration)
}
@Test("A disappearing message never downgrades to kind 1059")
@MainActor
func disappearingMessageWithoutSessionFailsClosed() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "transport-expiry-no-session")
}
)
let noiseKey = Data((104..<136).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "No Session"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must not become legacy",
to: peerID,
recipientNickname: "No Session",
messageID: "pm-expiry-no-session",
expiresAtSeconds: 4_000_000_000
)
Issue.record(
"Expected an expiring send without NDR to fail closed"
)
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .expiringMessageRequiresNdrSession = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("A durable NDR pin blocks legacy fallback with rollout off")
@MainActor
func durableNdrPinBlocksGateOffFallback() throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: false,
storageDirectoryProvider: {
try makeTempDir(label: "transport-gate-off-pin")
}
)
let noiseKey = Data(repeating: 0x7d, count: 32)
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Pinned"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
canUseNdrBindingForPeerID: { _ in true },
isNdrFallbackBlockedForPeerID: {
$0.toShort() == peerID.toShort()
},
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must remain pairwise",
to: peerID,
recipientNickname: "Pinned",
messageID: "pm-gate-off-pin"
)
Issue.record("Expected a pinned gate-off send to fail closed")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("A pairwise session is send-ready without a device roster")
@MainActor
func sendPrivateMessageDoesNotRequireDeviceRoster() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderStorage = try makeTempDir(label: "transport-ndr-queued-sender")
let recipientStorage = try makeTempDir(label: "transport-ndr-queued-recipient")
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: { senderStorage }
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: { recipientStorage }
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
#expect(!senderRelay.sentEvents.contains { $0.kind == 37368 })
#expect(!recipientRelay.sentEvents.contains { $0.kind == 37368 })
#expect(!senderRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true })
#expect(!recipientRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true })
senderRelay.resetSentEvents()
let probe = NostrTransportProbe()
let noiseKey = Data((80..<112).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Queued"
)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let transportUsed = try transport.sendPrivateMessageAndReturnTransport(
"pairwise ndr",
to: fullPeerID,
recipientNickname: "Queued",
messageID: "pm-pairwise-ndr"
)
#expect(transportUsed == .ndr)
#expect(probe.sentEvents.isEmpty)
#expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1)
}
@Test("An existing ratchet session never downgrades to legacy encryption")
@MainActor
func activeButNotSendReadySessionFailsClosed() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "fail-closed-sender")
}
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "fail-closed-recipient")
}
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
// Processing the response installs a receive path. Until its relay
// bootstrap arrives, this is intentionally not a send-ready session.
let senderInvite = try #require(senderNdr.currentInviteEventJson())
let response = try #require(
recipientNdr.processOutOfBandEventJson(
senderInvite,
expectedPeerPubkeyHex: sender.publicKeyHex,
persistEstablishedBinding: { true }
).first
)
recipientNdr.completeOutOfBandAction(response, succeeded: true)
_ = senderNdr.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: recipient.publicKeyHex,
persistEstablishedBinding: { true }
)
#expect(
senderNdr.hasPairwiseSession(with: recipient.publicKeyHex)
)
let noiseKey = Data((88..<120).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Fail Closed"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must not downgrade",
to: peerID,
recipientNickname: "Fail Closed",
messageID: "pm-fail-closed"
)
Issue.record("Expected the non-send-ready NDR session to fail")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
#expect(probe.sentEvents.isEmpty)
#expect(senderRelay.sentEvents.allSatisfy { $0.kind == 1060 })
}
@Test("An NDR storage-open failure never falls back to legacy encryption")
@MainActor
func ndrConfigurationFailureFailsClosed() throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let relay = FakeRelayManager()
let ndrService = NdrNostrService(
relayManager: relay,
rolloutEnabled: true,
storageDirectoryProvider: {
throw NostrTransportTestError.storageUnavailable
}
)
let noiseKey = Data((96..<128).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Corrupt State"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
sendEvent: probe.record(event:)
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
for _ in 0..<2 {
do {
_ = try transport.sendPrivateMessageAndReturnTransport(
"must remain failed",
to: peerID,
recipientNickname: "Corrupt State",
messageID: UUID().uuidString
)
Issue.record("Expected NDR configuration failure")
} catch let error as NostrTransport.OutboundPrivateMessageError {
guard case .ndrSessionFailure = error else {
Issue.record("Unexpected transport error: \(error)")
return
}
}
}
#expect(probe.sentEvents.isEmpty)
#expect(relay.sentEvents.isEmpty)
}
@Test("Favorite notification embeds current npub")
@MainActor
func sendFavoriteNotificationEmbedsCurrentIdentity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "favorite-notification")
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((96..<128).map(UInt8.init))
@ -194,6 +676,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
@ -222,6 +705,7 @@ struct NostrTransportTests {
func sendDeliveryAckEmitsDeliveredAck() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "delivery-ack")
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((128..<160).map(UInt8.init))
@ -235,6 +719,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
@ -259,17 +744,139 @@ struct NostrTransportTests {
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
}
@Test("Direct delivery and read ACKs stay on an established NDR session")
@MainActor
func directAcksUseNdrWhenSessionExists() async throws {
let keychain = MockKeychain()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let senderRelay = FakeRelayManager()
let recipientRelay = FakeRelayManager()
let senderNdr = NdrNostrService(
relayManager: senderRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "direct-ack-ndr-sender")
}
)
let recipientNdr = NdrNostrService(
relayManager: recipientRelay,
rolloutEnabled: true,
storageDirectoryProvider: {
try makeTempDir(label: "direct-ack-ndr-recipient")
}
)
senderNdr.configureIfNeeded(identity: sender)
recipientNdr.configureIfNeeded(identity: recipient)
try establishMutualSession(
senderNdr,
recipientNdr,
senderIdentity: sender,
recipientIdentity: recipient,
senderRelay: senderRelay,
recipientRelay: recipientRelay
)
senderRelay.resetSentEvents()
let noiseKey = Data((144..<176).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Ack peer"
)
let legacyProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: keychain),
ndrService: senderNdr,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
isNdrFallbackBlockedForPeerID: {
$0.toShort() == peerID.toShort()
},
currentIdentity: { sender },
sendEvent: legacyProbe.record(event:),
scheduleAfter: { delay, action in
legacyProbe.enqueueScheduledAction(
delay: delay,
action: action
)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
var decryptedMessages: [NdrDecryptedMessage] = []
recipientNdr.onDecryptedMessage = { message, completion in
decryptedMessages.append(message)
completion(.consumed)
}
transport.sendDeliveryAck(for: "ndr-delivered-1", to: peerID)
let deliveredSent = await TestHelpers.waitUntil({
senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1
})
#expect(deliveredSent)
let deliveredOuter = try #require(
senderRelay.sentEvents.first { $0.kind == 1060 }
)
recipientNdr.processInboundRelayEvent(deliveredOuter)
let receipt = ReadReceipt(
originalMessageID: "ndr-read-1",
readerID: transport.myPeerID,
readerNickname: "me"
)
transport.sendReadReceipt(receipt, to: peerID)
let readQueued = await TestHelpers.waitUntil({
legacyProbe.scheduledActionCount == 1
})
#expect(readQueued)
#expect(legacyProbe.runNextScheduledAction())
let readSent = await TestHelpers.waitUntil({
senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 2
})
#expect(readSent)
let readOuter = try #require(
senderRelay.sentEvents.filter { $0.kind == 1060 }.last
)
recipientNdr.processInboundRelayEvent(readOuter)
#expect(legacyProbe.sentEvents.isEmpty)
#expect(decryptedMessages.count == 2)
let deliveredPayload = try decodeNdrEmbeddedPayload(
from: decryptedMessages[0].event.content
)
#expect(deliveredPayload.type == .delivered)
#expect(
String(data: deliveredPayload.data, encoding: .utf8)
== "ndr-delivered-1"
)
let readPayload = try decodeNdrEmbeddedPayload(
from: decryptedMessages[1].event.content
)
#expect(readPayload.type == .readReceipt)
#expect(
String(data: readPayload.data, encoding: .utf8)
== "ndr-read-1"
)
}
@Test("Geohash private message registers pending gift wrap")
@MainActor
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "geohash-pm")
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
@ -305,6 +912,7 @@ struct NostrTransportTests {
func readReceiptQueueThrottlesSequentially() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let ndrService = try makeNdrService(label: "read-queue")
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((160..<192).map(UInt8.init))
@ -318,6 +926,7 @@ struct NostrTransportTests {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
ndrService: ndrService,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
@ -372,7 +981,8 @@ struct NostrTransportTests {
func concurrentReadReceiptEnqueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let ndrService = try makeNdrService(label: "concurrent-read")
let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
let iterations = 100
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
@ -397,7 +1007,8 @@ struct NostrTransportTests {
func isPeerReachableThreadSafety() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let ndrService = try makeNdrService(label: "reachable-thread-safety")
let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService)
let iterations = 100
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
@ -418,6 +1029,8 @@ struct NostrTransportTests {
loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] },
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true },
isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
@ -429,6 +1042,10 @@ struct NostrTransportTests {
loadFavorites: loadFavorites,
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID,
canUseNdrBindingForPeerID:
canUseNdrBindingForPeerID,
isNdrFallbackBlockedForPeerID:
isNdrFallbackBlockedForPeerID,
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
@ -437,6 +1054,16 @@ struct NostrTransportTests {
)
}
@MainActor
private func makeNdrService(label: String) throws -> NdrNostrService {
let storage = try makeTempDir(label: label)
return NdrNostrService(
relayManager: FakeRelayManager(),
rolloutEnabled: true,
storageDirectoryProvider: { storage }
)
}
private func makeRelationship(
peerNoisePublicKey: Data,
peerNostrPublicKey: String?,
@ -453,6 +1080,92 @@ struct NostrTransportTests {
)
}
private func makeTempDir(label: String) throws -> URL {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(
"bitchat-tests-\(label)-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
return dir
}
@MainActor
private func establishMutualSession(
_ senderService: NdrNostrService,
_ recipientService: NdrNostrService,
senderIdentity: NostrIdentity,
recipientIdentity: NostrIdentity,
senderRelay: FakeRelayManager,
recipientRelay: FakeRelayManager
) throws {
let senderRelayIndex = senderRelay.sentEvents.count
let recipientRelayIndex = recipientRelay.sentEvents.count
let senderInvite = try #require(
senderService.currentInviteEventJson()
)
let recipientInvite = try #require(
recipientService.currentInviteEventJson()
)
let generatedByRecipient =
recipientService.processOutOfBandEventJson(
senderInvite,
expectedPeerPubkeyHex: senderIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
let generatedBySender =
senderService.processOutOfBandEventJson(
recipientInvite,
expectedPeerPubkeyHex: recipientIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
for response in generatedByRecipient {
recipientService.completeOutOfBandAction(
response,
succeeded: true
)
_ = senderService.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: recipientIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
}
for response in generatedBySender {
senderService.completeOutOfBandAction(
response,
succeeded: true
)
_ = recipientService.processOutOfBandEventJson(
response.eventJson,
expectedPeerPubkeyHex: senderIdentity.publicKeyHex,
persistEstablishedBinding: { true }
)
}
for event in senderRelay.sentEvents
.dropFirst(senderRelayIndex)
where event.kind == 1060
{
recipientService.processInboundRelayEvent(event)
}
for event in recipientRelay.sentEvents
.dropFirst(recipientRelayIndex)
where event.kind == 1060
{
senderService.processInboundRelayEvent(event)
}
guard senderService.hasActiveSession(
with: recipientIdentity.publicKeyHex
),
recipientService.hasActiveSession(
with: senderIdentity.publicKeyHex
)
else {
throw NostrTransportTestError.failedToEstablishNdrSession
}
}
private func decodeEmbeddedPayload(
from event: NostrEvent,
recipient: NostrIdentity
@ -473,6 +1186,21 @@ struct NostrTransportTests {
return (packet, payload, senderPubkey)
}
private func decodeNdrEmbeddedPayload(
from content: String
) throws -> NoisePayload {
guard content.hasPrefix("bitchat1:") else {
throw NostrTransportTestError.invalidEmbeddedContent
}
let encoded = String(content.dropFirst("bitchat1:".count))
guard let packetData = base64URLDecode(encoded),
let packet = BitchatPacket.from(packetData),
let payload = NoisePayload.decode(packet.payload) else {
throw NostrTransportTestError.invalidPacket
}
return payload
}
private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket {
guard payload.type == .privateMessage,
let message = PrivateMessagePacket.decode(from: payload.data) else {
@ -480,12 +1208,15 @@ struct NostrTransportTests {
}
return message
}
}
private enum NostrTransportTestError: Error {
case invalidEmbeddedContent
case invalidPacket
case invalidPrivateMessage
case failedToEstablishNdrSession
case storageUnavailable
}
private func base64URLDecode(_ string: String) -> Data? {

View File

@ -40,6 +40,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// this bit; keep it decodable so the wire assignment is never reused.
public static let nonDestructiveNoiseReplacement =
PeerCapabilities(rawValue: 1 << 10)
/// Supports double-ratchet relay DMs with invite/response bootstrap carried
/// as Noise payload `0x22`. Both the capability and the payload value are
/// coordinated with Android before either feature branch can ship.
public static let doubleRatchet =
PeerCapabilities(rawValue: 1 << 11)
/// Minimal little-endian byte encoding; always at least one byte so an
/// empty set is distinguishable from an absent TLV.

View File

@ -27,8 +27,10 @@ struct PeerCapabilitiesTests {
== Data([0x00, 0x04])
)
let high = PeerCapabilities(rawValue: 1 << 11)
#expect(high.encoded() == Data([0x00, 0x08]))
#expect(
PeerCapabilities.doubleRatchet.encoded()
== Data([0x00, 0x08])
)
let all: PeerCapabilities = [
.prekeys,
@ -40,10 +42,15 @@ struct PeerCapabilitiesTests {
.meshDiagnostics,
.privateMedia,
.privateMediaReceipts,
.nonDestructiveNoiseReplacement
.nonDestructiveNoiseReplacement,
.doubleRatchet
]
#expect(PeerCapabilities(encoded: all.encoded()) == all)
#expect(PeerCapabilities(encoded: high.encoded()) == high)
#expect(
PeerCapabilities(
encoded: PeerCapabilities.doubleRatchet.encoded()
) == .doubleRatchet
)
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
}

View File

@ -0,0 +1,38 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "NdrFfi",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "NdrFfi",
targets: ["NdrFfi"]
)
],
targets: [
// Swift bindings generated by uniffi-bindgen
.target(
name: "NdrFfi",
dependencies: ["ndr_ffiFFI"],
path: "Sources/NdrFfi"
),
// Dynamic XCFramework built from nostr-double-ratchet's pairwise FFI. Keeping
// this runtime dynamic avoids linking a second Rust static runtime
// beside Arti's source-built static library.
.binaryTarget(
name: "ndr_ffiFFI",
path: "Frameworks/NdrFfi.xcframework"
),
// Tests
.testTarget(
name: "NdrFfiTests",
dependencies: ["NdrFfi"],
path: "Tests"
)
]
)

View File

@ -0,0 +1,77 @@
# NdrFfi
Generated Swift bindings and an ignored dynamic Apple XCFramework for the
single-device pairwise UniFFI crate in `nostr-double-ratchet`. The binary does
not include AppKeys, linked-device, sibling-sync, or group runtime code.
## Source Of Truth
The generated files in this package come from the upstream
`nostr-double-ratchet` checkout, specifically the Rust `ndr-pairwise-ffi`
crate and its UniFFI-generated Swift bindings. The built library keeps the
module name `ndr_ffi`.
The exact upstream revision is pinned by the
`vendor/nostr-double-ratchet` submodule and repeated in `SOURCE_REVISION`.
Native libraries are deliberately not tracked in this repository. Apple
deployment targets are fixed in the build script, so ambient shell settings
cannot change the output.
## Rebuild From Source
Prerequisites:
- Xcode and command line tools
- Rust toolchain version from `RUST_TOOLCHAIN`, with cargo
- Rust targets:
- `aarch64-apple-darwin`
- `x86_64-apple-darwin`
- `aarch64-apple-ios`
- `aarch64-apple-ios-sim`
- `x86_64-apple-ios`
Example:
```bash
rustup target add \
aarch64-apple-darwin \
x86_64-apple-darwin \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
git submodule update --init --checkout vendor/nostr-double-ratchet
./localPackages/NdrFfi/build-apple.sh
```
Or:
```bash
cd localPackages/NdrFfi
NOSTR_DOUBLE_RATCHET_DIR=/path/to/nostr-double-ratchet ./build-apple.sh
```
The script:
- builds the upstream `ndr-pairwise-ffi` crate
- reuses an ignored Cargo target cache under `.cache/ndr-ffi/apple`
- regenerates `Sources/NdrFfi/NdrFfi.swift` via UniFFI
- rebuilds the ignored dynamic Apple XCFramework at `Frameworks/NdrFfi.xcframework`
- bakes in the current Apple deployment targets used by `bitchat`
The NDR runtime is an embedded dynamic framework because the shipping app
already links Arti as a Rust static library. Rust static libraries each bundle
their own standard library and are not safe to combine independently in one
foreign-linker process.
## Outputs Updated By The Script
- `Sources/NdrFfi/NdrFfi.swift`
- `Frameworks/NdrFfi.xcframework` (local build output, ignored)
## Recommended Verification
```bash
swift test --package-path localPackages/NdrFfi
swift test --filter NdrOutOfBandTransportTests
swift test --filter NostrTransportTests
```

View File

@ -0,0 +1 @@
1.95.0

View File

@ -0,0 +1 @@
0fe8caf2d4e24e2030ffae195597a2764613a659

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,364 @@
import Foundation
import XCTest
@testable import NdrFfi
final class NdrFfiTests: XCTestCase {
func testVersionAndKeyGeneration() throws {
XCTAssertFalse(NdrFfi.version().isEmpty)
let first = generateKeypair()
let second = generateKeypair()
XCTAssertEqual(first.publicKeyHex.count, 64)
XCTAssertEqual(first.privateKeyHex.count, 64)
XCTAssertNotNil(Data(hexString: first.publicKeyHex))
XCTAssertNotNil(Data(hexString: first.privateKeyHex))
XCTAssertNotEqual(first.publicKeyHex, second.publicKeyHex)
XCTAssertNotEqual(first.privateKeyHex, second.privateKeyHex)
XCTAssertEqual(
try derivePublicKey(privateKeyHex: first.privateKeyHex),
first.publicKeyHex
)
}
func testCurrentInviteIsIdentityBoundKind30078() throws {
let keys = generateKeypair()
let manager = try makeManager(keys)
let inviteJSON = try manager.currentInviteEventJson()
let invite = try PairwiseInvite.fromEventJson(eventJson: inviteJSON)
XCTAssertEqual(try extractNostrKind(json: inviteJSON), 30078)
XCTAssertEqual(invite.getPeerPubkeyHex(), keys.publicKeyHex)
XCTAssertEqual(
try PairwiseInvite.fromUrl(
url: invite.toUrl(root: "https://b")
).getPeerPubkeyHex(),
keys.publicKeyHex
)
}
func testAuthenticatedHandshakeBecomesBidirectionallySendReady() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
let artifacts = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
XCTAssertEqual(artifacts.response.peerPubkeyHex, aliceKeys.publicKeyHex)
XCTAssertEqual(try extractNostrKind(json: artifacts.responseJSON), 1059)
XCTAssertEqual(try extractNostrKind(json: artifacts.bootstrapJSON), 1060)
XCTAssertEqual(
try alice.sessionInfo(peerPubkeyHex: bobKeys.publicKeyHex)?
.sendReady,
true
)
XCTAssertEqual(
try bob.sessionInfo(peerPubkeyHex: aliceKeys.publicKeyHex)?
.sendReady,
true
)
}
func testSendProducesDurableUnsignedDeliveryWithExpiration() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let expiration = UInt64(Date().timeIntervalSince1970) + 60
let result = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "hello from bob",
expiresAtSeconds: expiration
)
let publish = try requireAction(
in: bob,
kind: "publish",
outerEventID: result.outerEventId
)
let outerJSON = try XCTUnwrap(publish.eventJson)
try alice.processEvent(eventJson: outerJSON)
let delivery = try requireAction(
in: alice,
kind: "delivery",
innerEventID: result.innerEventId
)
let innerJSON = try XCTUnwrap(delivery.innerEventJson)
let inner = try jsonObject(innerJSON)
XCTAssertEqual(inner["kind"] as? Int, 14)
XCTAssertEqual(inner["pubkey"] as? String, bobKeys.publicKeyHex)
XCTAssertEqual(inner["content"] as? String, "hello from bob")
XCTAssertNil(inner["sig"] as? String)
XCTAssertEqual(delivery.peerPubkeyHex, bobKeys.publicKeyHex)
XCTAssertEqual(delivery.outerEventId, result.outerEventId)
XCTAssertEqual(delivery.expiresAtSeconds, expiration)
try bob.ackActions(actionIds: [publish.actionId])
try alice.ackActions(actionIds: [delivery.actionId])
XCTAssertFalse(
try bob.pendingActions().contains {
$0.actionId == publish.actionId
}
)
XCTAssertFalse(
try alice.pendingActions().contains {
$0.actionId == delivery.actionId
}
)
}
func testSameSecondSendsHaveDistinctIDs() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let first = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "first",
expiresAtSeconds: nil
)
let second = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "second",
expiresAtSeconds: nil
)
XCTAssertNotEqual(first.innerEventId, second.innerEventId)
XCTAssertNotEqual(first.outerEventId, second.outerEventId)
}
func testPendingPublishAndDeliverySurviveRestart() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"ndr-ffi-restart-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alicePath = root.appendingPathComponent("alice").path
let bobPath = root.appendingPathComponent("bob").path
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
var outerJSON = ""
var result: PairwiseSendResult?
do {
let alice = try makeManager(aliceKeys, storagePath: alicePath)
let bob = try makeManager(bobKeys, storagePath: bobPath)
_ = try establishSession(
inviter: alice,
inviterKeys: aliceKeys,
acceptor: bob,
acceptorKeys: bobKeys
)
let sent = try bob.sendText(
peerPubkeyHex: aliceKeys.publicKeyHex,
text: "survives restart",
expiresAtSeconds: nil
)
result = sent
outerJSON = try XCTUnwrap(
requireAction(
in: bob,
kind: "publish",
outerEventID: sent.outerEventId
).eventJson
)
}
let sent = try XCTUnwrap(result)
do {
let restoredBob = try makeManager(
bobKeys,
storagePath: bobPath
)
XCTAssertNotNil(
try restoredBob.pendingActions().first {
$0.outerEventId == sent.outerEventId
&& $0.kind == "publish"
}
)
}
do {
let restoredAlice = try makeManager(
aliceKeys,
storagePath: alicePath
)
try restoredAlice.processEvent(eventJson: outerJSON)
}
let restoredAgain = try makeManager(
aliceKeys,
storagePath: alicePath
)
let delivery = try requireAction(
in: restoredAgain,
kind: "delivery",
innerEventID: sent.innerEventId
)
XCTAssertEqual(
try jsonObject(
XCTUnwrap(delivery.innerEventJson)
)["content"] as? String,
"survives restart"
)
}
func testInvalidInviteAndAuthenticatedPeerMismatchAreRejected() throws {
let aliceKeys = generateKeypair()
let bobKeys = generateKeypair()
let unexpectedKeys = generateKeypair()
let alice = try makeManager(aliceKeys)
let bob = try makeManager(bobKeys)
XCTAssertThrowsError(
try bob.acceptInviteFromEventJson(
eventJson:
#"{"kind":1,"id":"bad","pubkey":"bad","created_at":0,"content":"","tags":[],"sig":"bad"}"#,
authenticatedPeerPubkeyHex: aliceKeys.publicKeyHex
)
)
XCTAssertThrowsError(
try bob.acceptInviteFromEventJson(
eventJson: alice.currentInviteEventJson(),
authenticatedPeerPubkeyHex: unexpectedKeys.publicKeyHex
)
)
}
private func makeManager(
_ keys: FfiKeyPair,
storagePath: String? = nil
) throws -> PairwiseManager {
let path: String
if let storagePath {
path = storagePath
} else {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ndr-ffi-test-\(UUID().uuidString)",
isDirectory: true
)
path = directory.path
addTeardownBlock {
try? FileManager.default.removeItem(at: directory)
}
}
return try PairwiseManager.newWithStoragePath(
ourPubkeyHex: keys.publicKeyHex,
ourIdentityPrivateKeyHex: keys.privateKeyHex,
storagePath: path
)
}
}
private struct HandshakeArtifacts {
let response: PairwiseAction
let responseJSON: String
let bootstrapJSON: String
}
private func establishSession(
inviter: PairwiseManager,
inviterKeys: FfiKeyPair,
acceptor: PairwiseManager,
acceptorKeys: FfiKeyPair
) throws -> HandshakeArtifacts {
let inviteJSON = try inviter.currentInviteEventJson()
let accepted = try acceptor.acceptInviteFromEventJson(
eventJson: inviteJSON,
authenticatedPeerPubkeyHex: inviterKeys.publicKeyHex
)
XCTAssertTrue(accepted.createdNewSession)
let response = try requireAction(in: acceptor, kind: "out_of_band")
let responseJSON = try XCTUnwrap(response.eventJson)
let bootstrap = try requireAction(in: acceptor, kind: "publish")
let bootstrapJSON = try XCTUnwrap(bootstrap.eventJson)
try inviter.processOutOfBandResponse(
eventJson: responseJSON,
authenticatedPeerPubkeyHex: acceptorKeys.publicKeyHex
)
XCTAssertEqual(
try inviter.sessionInfo(
peerPubkeyHex: acceptorKeys.publicKeyHex
)?.sendReady,
false
)
try inviter.processEvent(eventJson: bootstrapJSON)
try acceptor.ackActions(
actionIds: [response.actionId, bootstrap.actionId]
)
return HandshakeArtifacts(
response: response,
responseJSON: responseJSON,
bootstrapJSON: bootstrapJSON
)
}
private func requireAction(
in manager: PairwiseManager,
kind: String,
innerEventID: String? = nil,
outerEventID: String? = nil
) throws -> PairwiseAction {
try XCTUnwrap(
try manager.pendingActions().first { action in
action.kind == kind
&& (innerEventID == nil || action.innerEventId == innerEventID)
&& (outerEventID == nil || action.outerEventId == outerEventID)
},
"Expected pending \(kind) action"
)
}
private func extractNostrKind(json: String) throws -> Int {
try XCTUnwrap(
jsonObject(json)["kind"] as? Int,
"Event should have an integer kind"
)
}
private func jsonObject(_ json: String) throws -> [String: Any] {
try XCTUnwrap(
JSONSerialization.jsonObject(
with: Data(json.utf8),
options: []
) as? [String: Any],
"Expected a JSON object"
)
}
private extension Data {
init?(hexString: String) {
guard hexString.count.isMultiple(of: 2) else { return nil }
var data = Data(capacity: hexString.count / 2)
var index = hexString.startIndex
while index < hexString.endIndex {
let next = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(hexString[index..<next], radix: 16) else {
return nil
}
data.append(byte)
index = next
}
self = data
}
}

View File

@ -0,0 +1,25 @@
# Source-Build Provenance
`NdrFfi` is built from source using:
- Upstream repository: `https://github.com/irislib/nostr-double-ratchet`
- Upstream base: `master` at `c93f76a2b947f4288d2c7bcbecabe70ce197da5f`
- Pinned source commit: `0fe8caf2d4e24e2030ffae195597a2764613a659`
- Crate: `ndr-pairwise-ffi` (library `ndr_ffi`)
- Runtime: durable single-identity pairwise sessions only; no AppKeys,
linked-device, sibling-sync, or group runtime
- Rebuild script: `build-apple.sh`
- Rust compiler: `1.95.0` (pinned by `RUST_TOOLCHAIN`)
- Release Rust flags: `-C panic=abort -C strip=debuginfo`
- Packaging: embedded dynamic XCFramework, isolating its Rust runtime from
Arti's independent static Rust runtime
- Cargo builds use `--locked`.
- The pinned source commit provides durable pairwise state/action ordering,
targeted peer retirement, and portable exclusive storage locking.
Generated/build outputs:
- `Sources/NdrFfi/NdrFfi.swift` is tracked and checked for regeneration drift.
- `Frameworks/NdrFfi.xcframework` is rebuilt locally/CI and ignored.
Updated on `2026-07-27`.

View File

@ -0,0 +1,232 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$SCRIPT_DIR"
REPOSITORY_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)"
SOURCE_DIR="${1:-${NOSTR_DOUBLE_RATCHET_DIR:-$REPOSITORY_DIR/vendor/nostr-double-ratchet}}"
CRATE_DIR="$SOURCE_DIR/rust/crates/ndr-pairwise-ffi"
CRATE_MANIFEST="$CRATE_DIR/Cargo.toml"
BINDGEN_MANIFEST="$CRATE_MANIFEST"
EXPECTED_REVISION="$(tr -d '[:space:]' < "$PACKAGE_DIR/SOURCE_REVISION")"
EXPECTED_RUST="$(tr -d '[:space:]' < "$PACKAGE_DIR/RUST_TOOLCHAIN")"
# A user-level Cargo config may point at a compiler cache unavailable to CI or
# the current sandbox, so this reproducible build does not use an ambient wrapper.
export RUSTC_WRAPPER=""
# These are reproducibility inputs, not ambient build-machine preferences.
# Keep them aligned with the application's documented minimum OS versions.
MACOS_MIN="13.0"
IOS_MIN="16.0"
FRAMEWORK_NAME="ndr_ffiFFI"
INSTALL_NAME="@rpath/$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME"
if [[ ! -f "$CRATE_MANIFEST" ]]; then
echo "error: expected nostr-double-ratchet pairwise FFI crate at $CRATE_MANIFEST" >&2
exit 1
fi
if [[ ! -f "$BINDGEN_MANIFEST" ]]; then
echo "error: expected UniFFI bindgen manifest at $BINDGEN_MANIFEST" >&2
exit 1
fi
ACTUAL_RUST="$(rustc --version | awk '{print $2}')"
if [[ "$ACTUAL_RUST" != "$EXPECTED_RUST" ]]; then
echo "error: rustc $ACTUAL_RUST is active; NdrFfi is pinned to $EXPECTED_RUST" >&2
echo "install/select it with rustup before rebuilding" >&2
exit 1
fi
if ! git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "error: nostr-double-ratchet source must be the pinned Git submodule at $SOURCE_DIR" >&2
exit 1
fi
SOURCE_WORKTREE="$(cd "$SOURCE_DIR" && pwd -P)"
SOURCE_GIT_ROOT="$(git -C "$SOURCE_DIR" rev-parse --show-toplevel)"
if [[ "$SOURCE_GIT_ROOT" != "$SOURCE_WORKTREE" ]]; then
echo "error: nostr-double-ratchet Git root is $SOURCE_GIT_ROOT; expected $SOURCE_WORKTREE" >&2
exit 1
fi
ACTUAL_REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
if [[ "$ACTUAL_REVISION" != "$EXPECTED_REVISION" ]]; then
echo "error: nostr-double-ratchet is at $ACTUAL_REVISION; expected $EXPECTED_REVISION" >&2
echo "run: git submodule update --init --checkout vendor/nostr-double-ratchet" >&2
exit 1
fi
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain --untracked-files=all)" ]]; then
echo "error: nostr-double-ratchet source has local changes; refusing an unreproducible build" >&2
exit 1
fi
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ndrffi-apple.XXXXXX")"
TARGET_DIR="${NDR_FFI_TARGET_DIR:-$REPOSITORY_DIR/.cache/ndr-ffi/apple/target}"
OUT_DIR="$WORK_ROOT/out"
BINDINGS_DIR="$OUT_DIR/bindings"
HEADERS_DIR="$OUT_DIR/headers"
cleanup() {
rm -rf "$WORK_ROOT"
}
trap cleanup EXIT
mkdir -p "$TARGET_DIR" "$BINDINGS_DIR" "$HEADERS_DIR"
echo "==> Building the pairwise ndr_ffi artifacts from $CRATE_DIR"
echo " macOS minimum: $MACOS_MIN"
echo " iOS minimum: $IOS_MIN"
cd "$CRATE_DIR"
echo "==> Generating Swift bindings"
env \
CARGO_TARGET_DIR="$TARGET_DIR" \
cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib
env \
CARGO_TARGET_DIR="$TARGET_DIR" \
cargo run --locked --manifest-path "$BINDGEN_MANIFEST" --features bindgen --bin uniffi-bindgen -- \
generate \
--library "$TARGET_DIR/debug/libndr_ffi.dylib" \
--language swift \
--out-dir "$BINDINGS_DIR"
cp "$BINDINGS_DIR/ndr_ffiFFI.h" "$HEADERS_DIR/ndr_ffiFFI.h"
echo "==> Building dynamic macOS slices"
for target in aarch64-apple-darwin x86_64-apple-darwin; do
env \
CARGO_TARGET_DIR="$TARGET_DIR" \
MACOSX_DEPLOYMENT_TARGET="$MACOS_MIN" \
CFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
CXXFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
CFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
CXXFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \
RUSTFLAGS="-C panic=abort -C strip=debuginfo -C link-arg=-mmacosx-version-min=$MACOS_MIN -C link-arg=-Wl,-install_name,$INSTALL_NAME" \
cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib --release --target "$target"
done
echo "==> Building dynamic iOS slices"
for target in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do
env \
CARGO_TARGET_DIR="$TARGET_DIR" \
IPHONEOS_DEPLOYMENT_TARGET="$IOS_MIN" \
RUSTFLAGS="-C panic=abort -C strip=debuginfo -C link-arg=-Wl,-install_name,$INSTALL_NAME" \
cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib --release --target "$target"
done
MACOS_DYLIB="$OUT_DIR/libndr_ffi_macos.dylib"
SIM_DYLIB="$OUT_DIR/libndr_ffi_sim.dylib"
lipo -create \
"$TARGET_DIR/aarch64-apple-darwin/release/libndr_ffi.dylib" \
"$TARGET_DIR/x86_64-apple-darwin/release/libndr_ffi.dylib" \
-output "$MACOS_DYLIB"
lipo -create \
"$TARGET_DIR/aarch64-apple-ios-sim/release/libndr_ffi.dylib" \
"$TARGET_DIR/x86_64-apple-ios/release/libndr_ffi.dylib" \
-output "$SIM_DYLIB"
make_framework() {
local binary="$1"
local destination="$2"
local supported_platform="$3"
local minimum_version="$4"
local framework="$destination/$FRAMEWORK_NAME.framework"
local contents="$framework"
local plist="$framework/Info.plist"
if [[ "$supported_platform" == "MacOSX" ]]; then
contents="$framework/Versions/A"
plist="$contents/Resources/Info.plist"
mkdir -p "$contents/Headers" "$contents/Modules" "$contents/Resources"
else
mkdir -p "$contents/Headers" "$contents/Modules"
fi
cp "$binary" "$contents/$FRAMEWORK_NAME"
chmod +x "$contents/$FRAMEWORK_NAME"
cp "$HEADERS_DIR/ndr_ffiFFI.h" "$contents/Headers/ndr_ffiFFI.h"
cat > "$contents/Modules/module.modulemap" <<EOF
framework module $FRAMEWORK_NAME {
header "ndr_ffiFFI.h"
export *
}
EOF
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$FRAMEWORK_NAME</string>
<key>CFBundleIdentifier</key>
<string>chat.bitchat.ndrffi</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$FRAMEWORK_NAME</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>MinimumOSVersion</key>
<string>$minimum_version</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>$supported_platform</string>
</array>
</dict>
</plist>
EOF
if [[ "$supported_platform" == "MacOSX" ]]; then
ln -s A "$framework/Versions/Current"
ln -s "Versions/Current/$FRAMEWORK_NAME" "$framework/$FRAMEWORK_NAME"
ln -s Versions/Current/Headers "$framework/Headers"
ln -s Versions/Current/Modules "$framework/Modules"
ln -s Versions/Current/Resources "$framework/Resources"
fi
}
DEVICE_ROOT="$OUT_DIR/iphoneos"
SIM_ROOT="$OUT_DIR/iphonesimulator"
MACOS_ROOT="$OUT_DIR/macos"
make_framework \
"$TARGET_DIR/aarch64-apple-ios/release/libndr_ffi.dylib" \
"$DEVICE_ROOT" \
"iPhoneOS" \
"$IOS_MIN"
make_framework "$SIM_DYLIB" "$SIM_ROOT" "iPhoneSimulator" "$IOS_MIN"
make_framework "$MACOS_DYLIB" "$MACOS_ROOT" "MacOSX" "$MACOS_MIN"
echo "==> Assembling dynamic XCFramework"
xcodebuild -create-xcframework \
-framework "$DEVICE_ROOT/$FRAMEWORK_NAME.framework" \
-framework "$SIM_ROOT/$FRAMEWORK_NAME.framework" \
-framework "$MACOS_ROOT/$FRAMEWORK_NAME.framework" \
-output "$OUT_DIR/NdrFfi.xcframework"
echo "==> Updating generated package outputs"
LC_ALL=C sed -E 's/[[:blank:]]+$//' \
"$BINDINGS_DIR/ndr_ffi.swift" \
> "$BINDINGS_DIR/ndr_ffi.swift.normalized"
mv "$BINDINGS_DIR/ndr_ffi.swift.normalized" "$BINDINGS_DIR/ndr_ffi.swift"
cp "$BINDINGS_DIR/ndr_ffi.swift" "$PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift"
mkdir -p "$PACKAGE_DIR/Frameworks"
rm -rf "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework"
cp -R "$OUT_DIR/NdrFfi.xcframework" "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework"
echo "==> Verifying dynamic framework install names"
for binary in "$PACKAGE_DIR"/Frameworks/NdrFfi.xcframework/*/"$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME"; do
otool -D "$binary" | grep -F "$INSTALL_NAME" >/dev/null
done
echo "==> Done"
echo " Updated $PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift"
echo " Updated $PACKAGE_DIR/Frameworks/NdrFfi.xcframework"

1
vendor/nostr-double-ratchet vendored Submodule

@ -0,0 +1 @@
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659