mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge c654590f9ab090c4fd517b8876dccf33b3b4f0c6 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
b7dadebfab
15
.github/workflows/periphery.yml
vendored
15
.github/workflows/periphery.yml
vendored
@ -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.
|
||||
|
||||
37
.github/workflows/source-manifest.yml
vendored
37
.github/workflows/source-manifest.yml
vendored
@ -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
|
||||
|
||||
70
.github/workflows/swift-tests.yml
vendored
70
.github/workflows/swift-tests.yml
vendored
@ -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
3
.gitignore
vendored
@ -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
4
.gitmodules
vendored
Normal 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
|
||||
11
Justfile
11
Justfile
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
20
README.md
20
README.md
@ -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
|
||||
|
||||
15
bitchat.xcodeproj/project.pbxproj
generated
15
bitchat.xcodeproj/project.pbxproj
generated
@ -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 */;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
14
bitchat/Services/DoubleRatchetFeature.swift
Normal file
14
bitchat/Services/DoubleRatchetFeature.swift
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
1436
bitchat/Services/NdrNostrService.swift
Normal file
1436
bitchat/Services/NdrNostrService.swift
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
3064
bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift
Normal file
3064
bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
280
bitchatTests/Nostr/NostrIdentityBridgeLifecycleTests.swift
Normal file
280
bitchatTests/Nostr/NostrIdentityBridgeLifecycleTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -114,5 +114,6 @@ struct BLENoiseReconnectPolicyTests {
|
||||
#expect(
|
||||
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||
)
|
||||
#expect(!PeerCapabilities.localSupported.contains(.doubleRatchet))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()) == [])
|
||||
}
|
||||
|
||||
|
||||
38
localPackages/NdrFfi/Package.swift
Normal file
38
localPackages/NdrFfi/Package.swift
Normal 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"
|
||||
)
|
||||
]
|
||||
)
|
||||
77
localPackages/NdrFfi/README.md
Normal file
77
localPackages/NdrFfi/README.md
Normal 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
|
||||
```
|
||||
1
localPackages/NdrFfi/RUST_TOOLCHAIN
Normal file
1
localPackages/NdrFfi/RUST_TOOLCHAIN
Normal file
@ -0,0 +1 @@
|
||||
1.95.0
|
||||
1
localPackages/NdrFfi/SOURCE_REVISION
Normal file
1
localPackages/NdrFfi/SOURCE_REVISION
Normal file
@ -0,0 +1 @@
|
||||
0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
1707
localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift
Normal file
1707
localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift
Normal file
File diff suppressed because it is too large
Load Diff
364
localPackages/NdrFfi/Tests/NdrFfiTests.swift
Normal file
364
localPackages/NdrFfi/Tests/NdrFfiTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
25
localPackages/NdrFfi/VENDORED_FROM.md
Normal file
25
localPackages/NdrFfi/VENDORED_FROM.md
Normal 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`.
|
||||
232
localPackages/NdrFfi/build-apple.sh
Executable file
232
localPackages/NdrFfi/build-apple.sh
Executable 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
1
vendor/nostr-double-ratchet
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
Loading…
x
Reference in New Issue
Block a user