mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:45:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aaa8cc28f | ||
|
|
507cb19b91 | ||
|
|
6cc3a8cde7 |
@@ -25,18 +25,47 @@ jobs:
|
|||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
- name: Create update branch if changes
|
||||||
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# branch name with timestamp
|
||||||
|
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
- name: Push branch
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: Automated update of relay data
|
||||||
|
branch: ${{ steps.create_branch.outputs.branch }}
|
||||||
|
base: main
|
||||||
|
title: Automated update of relay data
|
||||||
|
body: |
|
||||||
|
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||||
|
labels: automated, georelays
|
||||||
|
|
||||||
|
- name: No changes
|
||||||
|
if: steps.create_branch.outputs.changed != 'true'
|
||||||
|
run: echo "No changes to relays/online_relays_gps.csv"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.1
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -26,7 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.product(name: "Tor", package: "Arti")
|
.product(name: "Tor", package: "Tor")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
Generated
+7
-5
@@ -14,6 +14,7 @@
|
|||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -161,6 +162,7 @@
|
|||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
@@ -343,7 +345,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -911,9 +913,9 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/Arti;
|
relativePath = localPackages/Tor;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@@ -922,8 +924,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = upToNextMajorVersion;
|
||||||
version = 0.21.1;
|
minimumVersion = 0.21.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -63,10 +63,6 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -279,3 +275,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var nilIfEmpty: String? {
|
||||||
|
self.isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ struct BitchatPacket: Codable {
|
|||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
var route: [Data]?
|
var route: [Data]?
|
||||||
var isRSR: Bool
|
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -34,11 +33,10 @@ struct BitchatPacket: Codable {
|
|||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = route
|
self.route = route
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
@@ -58,7 +56,6 @@ struct BitchatPacket: Codable {
|
|||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = nil
|
self.route = nil
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
@@ -88,8 +85,7 @@ struct BitchatPacket: Codable {
|
|||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
version: version,
|
version: version,
|
||||||
route: route,
|
route: route
|
||||||
isRSR: false // RSR flag is mutable and not part of the signature
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ struct RequestSyncPacket {
|
|||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
let types: SyncTypeFlags?
|
||||||
let sinceTimestamp: UInt64?
|
|
||||||
let fragmentIdFilter: String?
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
self.data = data
|
self.data = data
|
||||||
self.types = types
|
self.types = types
|
||||||
self.sinceTimestamp = sinceTimestamp
|
|
||||||
self.fragmentIdFilter = fragmentIdFilter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
@@ -40,24 +36,15 @@ struct RequestSyncPacket {
|
|||||||
if let typesData = types?.toData() {
|
if let typesData = types?.toData() {
|
||||||
putTLV(0x04, typesData)
|
putTLV(0x04, typesData)
|
||||||
}
|
}
|
||||||
if let ts = sinceTimestamp {
|
|
||||||
var tsBE = ts.bigEndian
|
|
||||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
|
||||||
}
|
|
||||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
|
||||||
putTLV(0x06, fidData)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||||
var off = 0
|
var off = 0
|
||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
var types: SyncTypeFlags? = nil
|
||||||
var sinceTimestamp: UInt64? = nil
|
|
||||||
var fragmentIdFilter: String? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -81,22 +68,12 @@ struct RequestSyncPacket {
|
|||||||
if let decoded = SyncTypeFlags.decode(v) {
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
types = decoded
|
types = decoded
|
||||||
}
|
}
|
||||||
case 0x05:
|
|
||||||
if v.count == 8 {
|
|
||||||
var ts: UInt64 = 0
|
|
||||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
|
||||||
sinceTimestamp = ts
|
|
||||||
}
|
|
||||||
case 0x06:
|
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,23 +165,19 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for replay protection
|
/// Check if nonce is valid for replay protection
|
||||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
|
||||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
|
||||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
|
||||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
if receivedNonce > highestReceivedNonce {
|
if receivedNonce > highestReceivedNonce {
|
||||||
return true // Always accept newer nonces
|
return true // Always accept newer nonces
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||||
let byteIndex = offset / 8
|
let byteIndex = offset / 8
|
||||||
let bitIndex = offset % 8
|
let bitIndex = offset % 8
|
||||||
|
|
||||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,20 +347,16 @@ final class NoiseCipherState {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||||
|
|
||||||
// BCH-01-010: Atomic nonce state update
|
|
||||||
// Both replay window marking and nonce increment must complete together
|
|
||||||
// to prevent state desynchronization. We perform both after successful
|
|
||||||
// decryption only, ensuring state consistency on any failure path.
|
|
||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
|
// Mark nonce as seen after successful decryption
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -463,36 +455,13 @@ final class NoiseSymmetricState {
|
|||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
|
||||||
// The chaining key and hash should not be retained after handshake completes
|
|
||||||
clearSensitiveData()
|
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
|
||||||
/// Called after split() to clear chaining key and hash per Noise spec
|
|
||||||
func clearSensitiveData() {
|
|
||||||
// Clear chaining key by overwriting with zeros
|
|
||||||
let chainingKeyCount = chainingKey.count
|
|
||||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
|
||||||
|
|
||||||
// Clear hash by overwriting with zeros
|
|
||||||
let hashCount = hash.count
|
|
||||||
hash = Data(repeating: 0, count: hashCount)
|
|
||||||
|
|
||||||
// Clear the internal cipher state
|
|
||||||
cipherState.clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF implementation
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -838,20 +807,16 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
|
||||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -912,47 +877,22 @@ enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
case nonceExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constant-Time Operations
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
|
||||||
/// This function compares two Data objects in constant time, preventing
|
|
||||||
/// information leakage via timing analysis.
|
|
||||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
|
||||||
guard a.count == b.count else { return false }
|
|
||||||
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for i in 0..<a.count {
|
|
||||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
|
||||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for byte in data {
|
|
||||||
result |= byte
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Validation
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// Checks for weak/invalid keys that could compromise security
|
||||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
|
||||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
// Check for all-zero key (point at infinity)
|
||||||
if constantTimeIsZero(keyData) {
|
if keyData.allSatisfy({ $0 == 0 }) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for low-order points that could enable small subgroup attacks
|
// Check for low-order points that could enable small subgroup attacks
|
||||||
// These are the known bad points for Curve25519
|
// These are the known bad points for Curve25519
|
||||||
let lowOrderPoints: [Data] = [
|
let lowOrderPoints: [Data] = [
|
||||||
@@ -973,21 +913,13 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check against known bad points
|
// Check against known bad points
|
||||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
if lowOrderPoints.contains(keyData) {
|
||||||
var foundBadPoint = false
|
|
||||||
for badPoint in lowOrderPoints {
|
|
||||||
if constantTimeCompare(keyData, badPoint) {
|
|
||||||
foundBadPoint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundBadPoint {
|
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create the key - CryptoKit will validate curve points internally
|
// Try to create the key - CryptoKit will validate curve points internally
|
||||||
do {
|
do {
|
||||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||||
|
|||||||
@@ -102,23 +102,23 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
// Generate response
|
// Generate response
|
||||||
@@ -128,20 +128,20 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -126,24 +125,6 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
|
||||||
/// Must contain empty content and NO nickname tag
|
|
||||||
static func createGeohashPresenceEvent(
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [["g", geohash]]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: tags,
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||||
|
|
||||||
|
// Reconnection timer
|
||||||
|
private var reconnectionTimer: Timer?
|
||||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
@@ -884,10 +887,10 @@ struct NostrFilter: Encodable {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [20000, 20001]
|
filter.kinds = [20000]
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["g": [geohash]]
|
filter.tagFilters = ["g": [geohash]]
|
||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ struct BinaryProtocol {
|
|||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
static let hasRoute: UInt8 = 0x08
|
static let hasRoute: UInt8 = 0x08
|
||||||
static let isRSR: UInt8 = 0x10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
@@ -162,9 +161,7 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
|
let originalRoute = packet.route ?? []
|
||||||
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
|
|
||||||
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
|
|
||||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||||
if hop.count == senderIDSize { return hop }
|
if hop.count == senderIDSize { return hop }
|
||||||
@@ -178,14 +175,13 @@ struct BinaryProtocol {
|
|||||||
let hasRoute = !sanitizedRoute.isEmpty
|
let hasRoute = !sanitizedRoute.isEmpty
|
||||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// payloadLength in header is payload-only (does NOT include route bytes)
|
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
|
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
let estimatedPayload = payloadDataSize
|
let estimatedPayload = payloadDataSize
|
||||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
var data = Data()
|
var data = Data()
|
||||||
@@ -203,11 +199,9 @@ struct BinaryProtocol {
|
|||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
// HAS_ROUTE is only valid for v2+ packets
|
if hasRoute { flags |= Flags.hasRoute }
|
||||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
|
||||||
if packet.isRSR { flags |= Flags.isRSR }
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
let length = UInt32(payloadDataSize)
|
let length = UInt32(payloadDataSize)
|
||||||
for shift in stride(from: 24, through: 0, by: -8) {
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
@@ -329,10 +323,7 @@ struct BinaryProtocol {
|
|||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
|
||||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
|
||||||
let isRSR = (flags & Flags.isRSR) != 0
|
|
||||||
|
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
guard let len = read32() else { return nil }
|
guard let len = read32() else { return nil }
|
||||||
@@ -352,24 +343,27 @@ struct BinaryProtocol {
|
|||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
|
|
||||||
var route: [Data]? = nil
|
var route: [Data]? = nil
|
||||||
if hasRoute {
|
var remainingPayloadBytes = payloadLength
|
||||||
guard let routeCount = read8() else { return nil }
|
|
||||||
|
if (flags & Flags.hasRoute) != 0 {
|
||||||
|
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||||
|
remainingPayloadBytes -= 1
|
||||||
if routeCount > 0 {
|
if routeCount > 0 {
|
||||||
var hops: [Data] = []
|
var hops: [Data] = []
|
||||||
for _ in 0..<Int(routeCount) {
|
for _ in 0..<Int(routeCount) {
|
||||||
guard let hop = readData(senderIDSize) else { return nil }
|
guard remainingPayloadBytes >= senderIDSize,
|
||||||
|
let hop = readData(senderIDSize) else { return nil }
|
||||||
|
remainingPayloadBytes -= senderIDSize
|
||||||
hops.append(hop)
|
hops.append(hop)
|
||||||
}
|
}
|
||||||
route = hops
|
route = hops
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||||
let originalSize: Int
|
let originalSize: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
guard let rawSize = read32() else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
@@ -378,9 +372,11 @@ struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
|
remainingPayloadBytes -= lengthFieldBytes
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = remainingPayloadBytes
|
||||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
|
|
||||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
guard compressionRatio <= 50_000.0 else {
|
guard compressionRatio <= 50_000.0 else {
|
||||||
@@ -392,7 +388,9 @@ struct BinaryProtocol {
|
|||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
guard remainingPayloadBytes >= 0,
|
||||||
|
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
payload = rawPayload
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,8 +411,7 @@ struct BinaryProtocol {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
version: version,
|
version: version,
|
||||||
route: route,
|
route: route
|
||||||
isRSR: isRSR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,22 @@ final class CommandProcessor {
|
|||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
|
/// Backward-compatible property for existing code
|
||||||
|
weak var chatViewModel: CommandContextProvider? {
|
||||||
|
get { contextProvider }
|
||||||
|
set { contextProvider = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.contextProvider = contextProvider
|
self.contextProvider = contextProvider
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backward-compatible initializer
|
||||||
|
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
|
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a command string
|
/// Process a command string
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||||
|
|||||||
@@ -83,9 +83,6 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
var map = participants[geohash] ?? [:]
|
var map = participants[geohash] ?? [:]
|
||||||
map[key] = Date()
|
map[key] = Date()
|
||||||
participants[geohash] = map
|
participants[geohash] = map
|
||||||
|
|
||||||
// Always notify observers that state has changed so counts in UI update
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
// Only refresh visible list if this geohash is currently active
|
// Only refresh visible list if this geohash is currently active
|
||||||
if activeGeohash == geohash {
|
if activeGeohash == geohash {
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
|
||||||
// to geohash location channels.
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
|
||||||
///
|
|
||||||
/// Behavior:
|
|
||||||
/// - Monitors location changes via LocationStateManager
|
|
||||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
|
||||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
|
||||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
|
||||||
@MainActor
|
|
||||||
final class GeohashPresenceService: ObservableObject {
|
|
||||||
static let shared = GeohashPresenceService()
|
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
|
||||||
private var heartbeatTimer: Timer?
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
// Loop interval range in seconds
|
|
||||||
private let loopMinInterval: TimeInterval = 40.0
|
|
||||||
private let loopMaxInterval: TimeInterval = 80.0
|
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
|
||||||
private let burstMinDelay: TimeInterval = 2.0
|
|
||||||
private let burstMaxDelay: TimeInterval = 5.0
|
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
|
||||||
private let allowedPrecisions: Set<Int> = [
|
|
||||||
GeohashChannelLevel.region.precision, // 2
|
|
||||||
GeohashChannelLevel.province.precision, // 4
|
|
||||||
GeohashChannelLevel.city.precision // 5
|
|
||||||
]
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
|
||||||
func start() {
|
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
// Monitor location channel changes
|
|
||||||
LocationStateManager.shared.$availableChannels
|
|
||||||
.dropFirst()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleLocationChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
|
||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleConnectivityChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleLocationChange() {
|
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
|
||||||
// to announce presence in the new zone, then reset the loop.
|
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleConnectivityChange() {
|
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
|
||||||
// If we were waiting for network, do it now
|
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleNextHeartbeat() {
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performHeartbeat() {
|
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
|
||||||
defer { scheduleNextHeartbeat() }
|
|
||||||
|
|
||||||
// 1. Check preconditions
|
|
||||||
guard TorManager.shared.isReady else {
|
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
|
||||||
if !TorManager.shared.isForeground() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get channels
|
|
||||||
let channels = LocationStateManager.shared.availableChannels
|
|
||||||
guard !channels.isEmpty else { return }
|
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
|
||||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
|
||||||
for channel in channels {
|
|
||||||
// Check privacy restriction
|
|
||||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
|
||||||
Task { @MainActor in
|
|
||||||
// Random delay for decorrelation
|
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func broadcastPresence(for geohash: String) {
|
|
||||||
do {
|
|
||||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: geohash,
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send via RelayManager
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,47 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
// MARK: - Keychain Error Types
|
|
||||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
|
||||||
|
|
||||||
/// Result of a keychain read operation with proper error classification
|
|
||||||
enum KeychainReadResult {
|
|
||||||
case success(Data)
|
|
||||||
case itemNotFound // Expected: key doesn't exist yet
|
|
||||||
case accessDenied // Critical: app lacks keychain access
|
|
||||||
case deviceLocked // Recoverable: device is locked
|
|
||||||
case authenticationFailed // Recoverable: biometric/passcode failed
|
|
||||||
case otherError(OSStatus) // Unexpected error
|
|
||||||
|
|
||||||
var isRecoverableError: Bool {
|
|
||||||
switch self {
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a keychain save operation with proper error classification
|
|
||||||
enum KeychainSaveResult {
|
|
||||||
case success
|
|
||||||
case duplicateItem // Can retry with update
|
|
||||||
case accessDenied // Critical: app lacks keychain access
|
|
||||||
case deviceLocked // Recoverable: device is locked
|
|
||||||
case storageFull // Critical: no space available
|
|
||||||
case otherError(OSStatus)
|
|
||||||
|
|
||||||
var isRecoverableError: Bool {
|
|
||||||
switch self {
|
|
||||||
case .duplicateItem, .deviceLocked:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol KeychainManagerProtocol {
|
protocol KeychainManagerProtocol {
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
func getIdentityKey(forKey key: String) -> Data?
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
@@ -62,12 +21,6 @@ protocol KeychainManagerProtocol {
|
|||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
|
||||||
// BCH-01-009: Methods with proper error classification
|
|
||||||
/// Get identity key with detailed result for error handling
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
|
|
||||||
/// Save identity key with detailed result for error handling
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
@@ -101,181 +54,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
|
||||||
|
|
||||||
/// Get identity key with detailed result for proper error handling
|
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to save data with detailed result and retry for transient errors
|
|
||||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
|
||||||
// Delete any existing item first to ensure clean state
|
|
||||||
_ = delete(forKey: key)
|
|
||||||
|
|
||||||
// Build base query
|
|
||||||
var base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
|
||||||
]
|
|
||||||
#if os(macOS)
|
|
||||||
base[kSecAttrSynchronizable as String] = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
|
||||||
var query = base
|
|
||||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(addAccessGroup: true)
|
|
||||||
if status == -34018 { // Missing entitlement, retry without access group
|
|
||||||
status = attempt(addAccessGroup: false)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
let status = attempt(addAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let result = classifySaveStatus(status)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .duplicateItem:
|
|
||||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
|
||||||
case .storageFull:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry transient errors with exponential backoff
|
|
||||||
if result.isRecoverableError && retryCount > 0 {
|
|
||||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
|
||||||
usleep(delayMs * 1000)
|
|
||||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
|
||||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to retrieve data with detailed result
|
|
||||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
|
||||||
var q = base
|
|
||||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(withAccessGroup: true)
|
|
||||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
|
||||||
#else
|
|
||||||
let status = attempt(withAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch readResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case - no logging needed for missing keys
|
|
||||||
break
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
|
||||||
case .authenticationFailed:
|
|
||||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return readResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain read status into meaningful categories
|
|
||||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
if let data = data {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .otherError(status)
|
|
||||||
case errSecItemNotFound:
|
|
||||||
return .itemNotFound
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
// Device is locked or in a state that doesn't allow keychain access
|
|
||||||
return .deviceLocked
|
|
||||||
case errSecAuthFailed:
|
|
||||||
return .authenticationFailed
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain save status into meaningful categories
|
|
||||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
return .success
|
|
||||||
case errSecDuplicateItem:
|
|
||||||
return .duplicateItem
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
return .deviceLocked
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
case errSecDiskFull:
|
|
||||||
return .storageFull
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Operations
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
private func save(_ value: String, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -6,114 +6,104 @@ final class MeshTopologyTracker {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||||
private let hopSize = 8
|
private let hopSize = 8
|
||||||
// Directed claims: Key claims to see Value (neighbors)
|
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
|
||||||
// Last time we received an update from a node
|
|
||||||
private var lastSeen: [RoutingID: Date] = [:]
|
|
||||||
|
|
||||||
// Maximum age for topology claims to be considered fresh for routing
|
|
||||||
// Routes computed using stale topology can fail when the network has changed
|
|
||||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeAll()
|
self.adjacency.removeAll()
|
||||||
self.lastSeen.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the topology with a node's self-reported neighbor list
|
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
guard let source = sanitize(sourceData) else { return }
|
|
||||||
// Sanitize neighbors and exclude self-loops
|
|
||||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims[source] = validNeighbors
|
var setA = self.adjacency[left] ?? []
|
||||||
self.lastSeen[source] = Date()
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||||
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
if var setA = self.adjacency[left] {
|
||||||
|
setA.remove(right)
|
||||||
|
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||||
|
}
|
||||||
|
if var setB = self.adjacency[right] {
|
||||||
|
setB.remove(left)
|
||||||
|
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePeer(_ data: Data?) {
|
func removePeer(_ data: Data?) {
|
||||||
guard let peer = sanitize(data) else { return }
|
guard let peer = sanitize(data) else { return }
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeValue(forKey: peer)
|
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
for neighbor in neighbors {
|
||||||
}
|
if var set = self.adjacency[neighbor] {
|
||||||
}
|
set.remove(peer)
|
||||||
|
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
}
|
||||||
func prune(olderThan age: TimeInterval) {
|
|
||||||
let deadline = Date().addingTimeInterval(-age)
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
|
||||||
for (peer, _) in stale {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
func recordRoute(_ hops: [Data]) {
|
||||||
|
let sanitized = hops.compactMap { sanitize($0) }
|
||||||
|
guard sanitized.count >= 2 else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
for idx in 0..<(sanitized.count - 1) {
|
||||||
|
let left = sanitized[idx]
|
||||||
|
let right = sanitized[idx + 1]
|
||||||
|
guard left != right else { continue }
|
||||||
|
|
||||||
|
var setA = self.adjacency[left] ?? []
|
||||||
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||||
if source == target { return [] } // Direct connection, no intermediate hops
|
if source == target { return [source] }
|
||||||
|
|
||||||
return queue.sync {
|
let graph = queue.sync { adjacency }
|
||||||
let now = Date()
|
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
|
||||||
|
|
||||||
// BFS
|
var visited: Set<RoutingID> = [source]
|
||||||
var visited: Set<RoutingID> = [source]
|
var queuePaths: [[RoutingID]] = [[source]]
|
||||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
var index = 0
|
||||||
var queuePaths: [[RoutingID]] = [[source]]
|
|
||||||
|
|
||||||
while !queuePaths.isEmpty {
|
while index < queuePaths.count {
|
||||||
let path = queuePaths.removeFirst()
|
let path = queuePaths[index]
|
||||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
index += 1
|
||||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
guard path.count <= maxHops else { continue }
|
||||||
if path.count > maxHops + 1 { continue }
|
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||||
|
|
||||||
guard let last = path.last else { continue }
|
for neighbor in neighbors {
|
||||||
|
if visited.contains(neighbor) { continue }
|
||||||
// Get neighbors that 'last' claims to see
|
var nextPath = path
|
||||||
guard let neighbors = claims[last] else { continue }
|
nextPath.append(neighbor)
|
||||||
|
if neighbor == target { return nextPath }
|
||||||
// Check if 'last' node's topology info is fresh
|
if nextPath.count <= maxHops {
|
||||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if visited.contains(neighbor) { continue }
|
|
||||||
|
|
||||||
// CONFIRMED EDGE CHECK:
|
|
||||||
// 'last' claims 'neighbor' (checked above)
|
|
||||||
// Does 'neighbor' claim 'last'?
|
|
||||||
guard let neighborClaims = claims[neighbor],
|
|
||||||
neighborClaims.contains(last) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 'neighbor' node's topology info is fresh
|
|
||||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip edges to stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextPath = path
|
|
||||||
nextPath.append(neighbor)
|
|
||||||
|
|
||||||
if neighbor == target {
|
|
||||||
// Return only intermediate hops
|
|
||||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
|
||||||
return Array(nextPath.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
|
|
||||||
visited.insert(neighbor)
|
|
||||||
queuePaths.append(nextPath)
|
queuePaths.append(nextPath)
|
||||||
}
|
}
|
||||||
|
visited.insert(neighbor)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -5,20 +5,7 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
|
||||||
private struct QueuedMessage {
|
|
||||||
let content: String
|
|
||||||
let nickname: String
|
|
||||||
let messageID: String
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
|
||||||
private static let maxMessagesPerPeer = 100
|
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
init(transports: [Transport]) {
|
||||||
self.transports = transports
|
self.transports = transports
|
||||||
@@ -47,60 +34,52 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerReachable(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerConnected(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Sending
|
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
// Try to find a reachable transport
|
||||||
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later with timestamp for TTL tracking
|
// Queue for later
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
||||||
outbox[peerID]?.append(message)
|
|
||||||
|
|
||||||
// Enforce per-peer size limit with FIFO eviction
|
|
||||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
|
||||||
let evicted = outbox[peerID]?.removeFirst()
|
|
||||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
transport.sendReadReceipt(receipt, to: peerID)
|
||||||
} else if !transports.isEmpty {
|
} else if !transports.isEmpty {
|
||||||
|
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
||||||
|
// Or better: just try the first one that supports it?
|
||||||
|
// Existing logic preferred mesh, then nostr.
|
||||||
|
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
||||||
|
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
||||||
|
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
||||||
|
// But let's stick to the reachable check.
|
||||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
} else {
|
||||||
|
// Fallback: try all? or just the last one?
|
||||||
|
// Old logic: if mesh connected, mesh. Else nostr.
|
||||||
|
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
||||||
|
// If not mapped, we can't send via Nostr anyway.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,25 +88,17 @@ final class MessageRouter {
|
|||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
let now = Date()
|
|
||||||
var remaining: [QueuedMessage] = []
|
for (content, nickname, messageID) in queued {
|
||||||
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
for message in queued {
|
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Skip expired messages (TTL exceeded)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
|
||||||
} else {
|
} else {
|
||||||
remaining.append(message)
|
remaining.append((content, nickname, messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining.isEmpty {
|
if remaining.isEmpty {
|
||||||
outbox.removeValue(forKey: peerID)
|
outbox.removeValue(forKey: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -138,15 +109,4 @@ final class MessageRouter {
|
|||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodically clean up expired messages from all outboxes
|
|
||||||
func cleanupExpiredMessages() {
|
|
||||||
let now = Date()
|
|
||||||
for peerID in Array(outbox.keys) {
|
|
||||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
|
||||||
if outbox[peerID]?.isEmpty == true {
|
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,153 +199,64 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
// Load or create static identity key (ONLY from keychain)
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain with proper error classification
|
// Try to load from keychain
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
switch noiseKeyResult {
|
loadedKey = key
|
||||||
case .success(let identityData):
|
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||||
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
|
||||||
loadedKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key (will be lost on restart)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no identity exists, create new one
|
||||||
|
else {
|
||||||
|
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
|
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the final value
|
// Now assign the final value
|
||||||
self.staticIdentityKey = loadedKey
|
self.staticIdentityKey = loadedKey
|
||||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
||||||
|
|
||||||
// BCH-01-009: Load or create signing key pair with proper error handling
|
// Load or create signing key pair
|
||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
// Try to load from keychain
|
||||||
|
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
switch signingKeyResult {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
case .success(let signingData):
|
loadedSigningKey = key
|
||||||
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
loadedSigningKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no signing key exists, create new one
|
||||||
|
else {
|
||||||
|
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||||
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
|
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the signing keys
|
// Now assign the signing keys
|
||||||
self.signingKey = loadedSigningKey
|
self.signingKey = loadedSigningKey
|
||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
|
|
||||||
|
|
||||||
/// Generate and save a new Noise static key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
|
|
||||||
let newKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist noise static key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
|
|
||||||
let newKey = Curve25519.Signing.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist signing key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@@ -616,17 +527,6 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
rateLimiter.resetAll()
|
rateLimiter.resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
|
||||||
func clearSession(for peerID: PeerID) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -118,15 +118,32 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
// Convert recipient npub -> hex (x-only)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else {
|
||||||
|
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,31 +158,58 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,17 +221,21 @@ extension NostrTransport {
|
|||||||
// MARK: Geohash ACK helpers
|
// MARK: Geohash ACK helpers
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +243,19 @@ extension NostrTransport {
|
|||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !recipientHex.isEmpty else { return }
|
guard !recipientHex.isEmpty else { return }
|
||||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,32 +263,6 @@ extension NostrTransport {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
extension NostrTransport {
|
extension NostrTransport {
|
||||||
/// Converts npub bech32 string to hex pubkey
|
|
||||||
@MainActor
|
|
||||||
private func npubToHex(_ npub: String) -> String? {
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(npub)
|
|
||||||
guard hrp == "npub" else { return nil }
|
|
||||||
return data.hexEncodedString()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
|
||||||
@MainActor
|
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if registerPending {
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
}
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
/// Must be called within a barrier on `queue`
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
@@ -246,16 +275,31 @@ extension NostrTransport {
|
|||||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||||
private func sendReadAckItem(_ item: QueuedRead) {
|
private func sendReadAckItem(_ item: QueuedRead) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { scheduleNextReadAck() }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
// Convert recipient npub -> hex
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
do {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
|
||||||
|
scheduleNextReadAck()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
scheduleNextReadAck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ struct NotificationStreamAssembler {
|
|||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
|
|
||||||
|
|
||||||
let lengthOffset = 12
|
let lengthOffset = 12
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
@@ -81,15 +80,6 @@ struct NotificationStreamAssembler {
|
|||||||
var frameLength = framePrefix + payloadLength
|
var frameLength = framePrefix + payloadLength
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
|
|
||||||
let routeCountIndex = buffer.startIndex + routeCountOffset
|
|
||||||
guard buffer.count > routeCountOffset else { break }
|
|
||||||
let routeCount = Int(buffer[routeCountIndex])
|
|
||||||
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
if payloadLength < rawLengthFieldBytes {
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ protocol Transport: AnyObject {
|
|||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
|
||||||
func acceptPendingFile(id: String) -> URL?
|
|
||||||
func declinePendingFile(id: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
@@ -74,9 +70,6 @@ extension Transport {
|
|||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
sendMessage(content, mentions: mentions)
|
sendMessage(content, mentions: mentions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptPendingFile(id: String) -> URL? { nil }
|
|
||||||
func declinePendingFile(id: String) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -96,12 +96,11 @@ enum TransportConfig {
|
|||||||
// Keep scanning fully ON when we saw traffic very recently
|
// Keep scanning fully ON when we saw traffic very recently
|
||||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
static let bleExpectedWriteMaxMs: Int = 5000
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
static let bleFragmentSpacingMs: Int = 30
|
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
@@ -163,14 +162,6 @@ enum TransportConfig {
|
|||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
// BCH-01-004: Rate-limiting for subscription-triggered announces
|
|
||||||
// Prevents rapid enumeration attacks by rate-limiting announce responses
|
|
||||||
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
|
|
||||||
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
|
|
||||||
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
|
|
||||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
// Store-and-forward for directed packets at relays
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||||
|
|
||||||
@@ -212,18 +203,4 @@ enum TransportConfig {
|
|||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
// Gossip Sync Configuration
|
|
||||||
static let syncSeenCapacity: Int = 1000
|
|
||||||
static let syncGCSMaxBytes: Int = 400
|
|
||||||
static let syncGCSTargetFpr: Double = 0.01
|
|
||||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
|
||||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
|
||||||
static let syncFragmentCapacity: Int = 600
|
|
||||||
static let syncFileTransferCapacity: Int = 200
|
|
||||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
// Gossip-based sync manager using on-demand GCS filters
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
@@ -7,7 +6,6 @@ final class GossipSyncManager {
|
|||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
func getConnectedPeers() -> [PeerID]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PacketStore {
|
private struct PacketStore {
|
||||||
@@ -76,7 +74,6 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
private let requestSyncManager: RequestSyncManager
|
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
@@ -91,10 +88,9 @@ final class GossipSyncManager {
|
|||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
private var syncSchedules: [SyncSchedule] = []
|
private var syncSchedules: [SyncSchedule] = []
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
self.requestSyncManager = requestSyncManager
|
|
||||||
var schedules: [SyncSchedule] = []
|
var schedules: [SyncSchedule] = []
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
@@ -206,19 +202,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
|
||||||
// Unicast sync to connected peers to allow RSR attribution
|
|
||||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
|
||||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
|
||||||
for peerID in connectedPeers {
|
|
||||||
sendRequestSync(to: peerID, types: types)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to broadcast (discovery phase)
|
|
||||||
sendRequestSync(for: types)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload(for: types)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
@@ -235,9 +218,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
// Register the request for RSR validation
|
|
||||||
requestSyncManager.registerRequest(to: peerID)
|
|
||||||
|
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload(for: types)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
@@ -282,7 +262,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +274,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +286,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +298,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,13 +366,11 @@ final class GossipSyncManager {
|
|||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
|
||||||
|
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
sendRequestSync(for: syncSchedules[index].types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
//
|
|
||||||
// RequestSyncManager.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Manages outgoing sync requests and validates incoming responses.
|
|
||||||
///
|
|
||||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
|
||||||
/// that we have actively requested sync from.
|
|
||||||
final class RequestSyncManager {
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
|
||||||
|
|
||||||
// Allow responses for 30s after request
|
|
||||||
private let responseWindow: TimeInterval = 30.0
|
|
||||||
|
|
||||||
/// Register that we are sending a sync request to a peer.
|
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
|
||||||
func registerRequest(to peerID: PeerID) {
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
|
||||||
self.pendingRequests[peerID] = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a packet from a peer is a valid response to a sync request.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The sender of the packet
|
|
||||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
|
||||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
|
||||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
|
||||||
guard isRSR else { return false }
|
|
||||||
|
|
||||||
return queue.sync {
|
|
||||||
guard let requestTime = pendingRequests[peerID] else {
|
|
||||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
if now - requestTime > responseWindow {
|
|
||||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
|
||||||
// We don't remove here because we might receive multiple packets for one request
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Periodic cleanup of expired requests
|
|
||||||
func cleanup() {
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let originalCount = self.pendingRequests.count
|
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
|
||||||
now - timestamp <= self.responseWindow
|
|
||||||
}
|
|
||||||
let removed = originalCount - self.pendingRequests.count
|
|
||||||
if removed > 0 {
|
|
||||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,13 +52,11 @@ struct InputValidator {
|
|||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||||
|
|
||||||
/// Validates timestamp is reasonable (not too far in past or future)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
|
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
// 5 minutes = 300 seconds (industry standard for replay protection)
|
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||||
let fiveMinutesAgo = now.addingTimeInterval(-300)
|
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||||
let fiveMinutesFromNow = now.addingTimeInterval(300)
|
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||||
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||||
// Show Tor status once per app launch
|
// Show Tor status once per app launch
|
||||||
var torStatusAnnounced = false
|
var torStatusAnnounced = false
|
||||||
|
private var torProgressCancellable: AnyCancellable?
|
||||||
|
private var lastTorProgressAnnounced = -1
|
||||||
// Track whether a Tor restart is pending so we only announce
|
// Track whether a Tor restart is pending so we only announce
|
||||||
// "tor restarted" after an actual restart, not the first launch.
|
// "tor restarted" after an actual restart, not the first launch.
|
||||||
var torRestartPending: Bool = false
|
var torRestartPending: Bool = false
|
||||||
@@ -321,6 +323,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
)
|
)
|
||||||
// Channel activity tracking for background nudges
|
// Channel activity tracking for background nudges
|
||||||
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||||
|
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||||
|
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
|
||||||
// Geohash participant tracker
|
// Geohash participant tracker
|
||||||
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
// Participants who indicated they teleported (by tag in their events)
|
// Participants who indicated they teleported (by tag in their events)
|
||||||
@@ -444,7 +448,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
self.deduplicationService = MessageDeduplicationService()
|
self.deduplicationService = MessageDeduplicationService()
|
||||||
|
|
||||||
// Wire up dependencies
|
// Wire up dependencies
|
||||||
self.commandProcessor.contextProvider = self
|
self.commandProcessor.chatViewModel = self
|
||||||
self.participantTracker.configure(context: self)
|
self.participantTracker.configure(context: self)
|
||||||
|
|
||||||
// Subscribe to privateChatManager changes to trigger UI updates
|
// Subscribe to privateChatManager changes to trigger UI updates
|
||||||
@@ -499,6 +503,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
addGeohashOnlySystemMessage(
|
addGeohashOnlySystemMessage(
|
||||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||||
)
|
)
|
||||||
|
// Suppress incremental Tor progress messages
|
||||||
|
torProgressCancellable = nil
|
||||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||||
torStatusAnnounced = true
|
torStatusAnnounced = true
|
||||||
addGeohashOnlySystemMessage(
|
addGeohashOnlySystemMessage(
|
||||||
@@ -625,6 +631,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Listen for delivery acknowledgments
|
||||||
|
|
||||||
// When app becomes active, send read receipts for visible messages
|
// When app becomes active, send read receipts for visible messages
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -1460,6 +1468,56 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
selectedPrivateChatFingerprint = nil
|
selectedPrivateChatFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Nostr Message Handling
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc private func handleNostrMessage(_ notification: Notification) {
|
||||||
|
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
|
||||||
|
|
||||||
|
// Store the Nostr pubkey if provided (for messages from unknown senders)
|
||||||
|
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
|
||||||
|
let senderPeerID = message.senderPeerID {
|
||||||
|
// Store mapping for read receipts
|
||||||
|
nostrKeyMapping[senderPeerID] = nostrPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the Nostr message through the same flow as Bluetooth messages
|
||||||
|
didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
|
||||||
|
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update the delivery status for the message
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
// Update delivery status to delivered
|
||||||
|
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||||
|
|
||||||
|
// Schedule UI update for delivery status
|
||||||
|
// UI will update automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update in private chats if it's a private message
|
||||||
|
for (peerID, chatMessages) in privateChats {
|
||||||
|
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||||
|
// UI will update automatically
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleNostrReadReceipt(_ notification: Notification) {
|
||||||
|
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
|
||||||
|
|
||||||
|
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
|
||||||
|
|
||||||
|
// Process the read receipt through the same flow as Bluetooth read receipts
|
||||||
|
didReceiveReadReceipt(receipt)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
||||||
// Update private chat peer if needed when peer status changes
|
// Update private chat peer if needed when peer status changes
|
||||||
@@ -1997,42 +2055,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots
|
|
||||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
#if os(iOS)
|
|
||||||
Self.clearAppSwitcherSnapshots()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
// Force immediate UI update for panic mode
|
||||||
// UI updates immediately - no flushing needed
|
// UI updates immediately - no flushing needed
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
|
||||||
/// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
/// These could reveal sensitive information visible in the app at the time
|
|
||||||
#if os(iOS)
|
|
||||||
private nonisolated static func clearAppSwitcherSnapshots() {
|
|
||||||
do {
|
|
||||||
let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
|
||||||
let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true)
|
|
||||||
|
|
||||||
// Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene)
|
|
||||||
if FileManager.default.fileExists(atPath: snapshotsDir.path) {
|
|
||||||
let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil)
|
|
||||||
for item in contents {
|
|
||||||
try FileManager.default.removeItem(at: item)
|
|
||||||
}
|
|
||||||
SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Autocomplete
|
// MARK: - Autocomplete
|
||||||
|
|
||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
@@ -3017,91 +3046,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find message index trying both short (16-hex) and long (64-hex) peer ID formats.
|
|
||||||
/// Returns the peer ID where the message was found and its index, or nil if not found.
|
|
||||||
private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? {
|
|
||||||
// Try direct lookup first
|
|
||||||
if let messages = privateChats[peerID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (peerID, idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with full noise key if peerID is short (16 hex chars)
|
|
||||||
if peerID.bare.count == 16,
|
|
||||||
let peer = unifiedPeerService.getPeer(by: peerID),
|
|
||||||
!peer.noisePublicKey.isEmpty {
|
|
||||||
let longID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
if let messages = privateChats[longID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (longID, idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with short form if peerID is long (64 hex = noise key)
|
|
||||||
if peerID.bare.count == 64 {
|
|
||||||
let shortID = peerID.toShort()
|
|
||||||
if let messages = privateChats[shortID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (shortID, idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Low-level BLE events
|
// Low-level BLE events
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch type {
|
switch type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
||||||
|
|
||||||
// BCH-01-012: Check blocking before processing private message to prevent notification bypass
|
|
||||||
if isPeerBlocked(peerID) {
|
|
||||||
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
||||||
let pmMentions = parseMentions(from: pm.content)
|
let pmMentions = parseMentions(from: pm.content)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: pm.messageID,
|
id: pm.messageID,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
content: pm.content,
|
content: pm.content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: nickname,
|
recipientNickname: nickname,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
mentions: pmMentions.isEmpty ? nil : pmMentions
|
mentions: pmMentions.isEmpty ? nil : pmMentions
|
||||||
)
|
)
|
||||||
handlePrivateMessage(msg)
|
handlePrivateMessage(msg)
|
||||||
// Send delivery ACK back over BLE
|
// Send delivery ACK back over BLE
|
||||||
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
||||||
|
|
||||||
case .delivered:
|
case .delivered:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
|
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||||
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
|
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
||||||
// Don't downgrade from .read to .delivered
|
objectWillChange.send()
|
||||||
if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return }
|
}
|
||||||
|
}
|
||||||
privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
|
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||||
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
|
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
|
||||||
// Explicitly unwrap and re-assign to ensure the @Published setter is called
|
objectWillChange.send()
|
||||||
if let messages = privateChats[foundPeerID], idx < messages.count {
|
}
|
||||||
messages[idx].deliveryStatus = .read(by: name, at: Date())
|
|
||||||
privateChats[foundPeerID] = messages
|
|
||||||
privateChatManager.objectWillChange.send()
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
}
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
// Parse and respond
|
// Parse and respond
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
|
||||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -87,11 +86,6 @@ extension ChatViewModel {
|
|||||||
// Update participants last-seen for this pubkey
|
// Update participants last-seen for this pubkey
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -130,21 +124,12 @@ extension ChatViewModel {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// BCH-01-012: Check blocking before any notifications
|
|
||||||
// handlePublicMessage has its own blocking check but returns silently,
|
|
||||||
// so we must also guard checkForMentions to prevent notification bypass
|
|
||||||
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
|
||||||
|
|
||||||
handlePublicMessage(msg)
|
handlePublicMessage(msg)
|
||||||
|
checkForMentions(msg)
|
||||||
// Only check mentions and send haptic if sender is not blocked
|
sendHapticFeedback(for: msg)
|
||||||
if !isBlocked {
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||||
@@ -254,9 +239,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
// Only handle ephemeral kind 20000 with matching tag
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||||
@@ -266,11 +250,6 @@ extension ChatViewModel {
|
|||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||||
|
|
||||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -294,9 +273,6 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
if isSelf {
|
if isSelf {
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
@@ -311,14 +287,17 @@ extension ChatViewModel {
|
|||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
geoNicknames[event.pubkey.lowercased()] = nick
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||||
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Store mapping for geohash DM initiation
|
// Store mapping for geohash DM initiation
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
// Update participants last-seen for this pubkey
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content
|
let content = event.content
|
||||||
@@ -485,8 +464,7 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||||
let existingCount = participantTracker.participantCount(for: gh)
|
let existingCount = participantTracker.participantCount(for: gh)
|
||||||
|
|||||||
@@ -740,11 +740,15 @@ extension ChatViewModel {
|
|||||||
|
|
||||||
if isViewing {
|
if isViewing {
|
||||||
// Mark read immediately if viewing
|
// Mark read immediately if viewing
|
||||||
// Use the incoming peerID directly - it has the established Noise session.
|
// Use router to send read receipt
|
||||||
// Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.
|
|
||||||
// Use meshService directly (not messageRouter) so it queues if peer disconnects.
|
|
||||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||||
meshService.sendReadReceipt(receipt, to: peerID)
|
if let key = noiseKey {
|
||||||
|
// Send via router to stable key if available (preferred for persistence/Nostr fallback)
|
||||||
|
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
|
} else {
|
||||||
|
// Fallback to mesh direct
|
||||||
|
meshService.sendReadReceipt(receipt, to: peerID)
|
||||||
|
}
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
} else {
|
} else {
|
||||||
// Notify
|
// Notify
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleTorWillRestart() {
|
@objc func handleTorWillRestart() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.torRestartPending = true
|
self.torRestartPending = true
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ struct AppInfoView: View {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Warning {
|
||||||
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -188,6 +192,24 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
FeatureRow(info: Strings.Privacy.panic)
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
SectionHeader(Strings.Warning.title)
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
|
Text(Strings.Warning.message)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
.padding(.top)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,22 +15,10 @@ struct PaymentChipView: View {
|
|||||||
enum PaymentType {
|
enum PaymentType {
|
||||||
case cashu(String)
|
case cashu(String)
|
||||||
case lightning(String)
|
case lightning(String)
|
||||||
|
|
||||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
|
|
||||||
private static func cashuURL(from link: String) -> URL? {
|
|
||||||
if let url = URL(string: link), url.scheme != nil {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
|
||||||
return URL(string: "cashu:\(enc)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var url: URL? {
|
var url: URL? {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu(let link):
|
case .cashu(let link), .lightning(let link):
|
||||||
return Self.cashuURL(from: link)
|
|
||||||
case .lightning(let link):
|
|
||||||
return URL(string: link)
|
return URL(string: link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ struct ContentView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var showAppInfo = false
|
@State private var showAppInfo = false
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@@ -56,6 +57,7 @@ struct ContentView: View {
|
|||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
|
@State private var sheetNotesCount: Int = 0
|
||||||
@State private var imagePreviewURL: URL? = nil
|
@State private var imagePreviewURL: URL? = nil
|
||||||
@State private var recordingAlertMessage: String = ""
|
@State private var recordingAlertMessage: String = ""
|
||||||
@State private var showRecordingAlert = false
|
@State private var showRecordingAlert = false
|
||||||
@@ -1179,6 +1181,19 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split a name into base and a '#abcd' suffix if present
|
||||||
|
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
|
||||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -1293,8 +1308,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Bookmark toggle (geochats): to the left of #geohash
|
// Bookmark toggle (geochats): to the left of #geohash
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
if case .location(let ch) = locationManager.selectedChannel {
|
||||||
Button(action: { bookmarks.toggle(ch.geohash) }) {
|
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1552,7 +1567,7 @@ private extension ContentView {
|
|||||||
} else if let media = mediaAttachment(for: message) {
|
} else if let media = mediaAttachment(for: message) {
|
||||||
mediaMessageRow(message: message, media: media)
|
mediaMessageRow(message: message, media: media)
|
||||||
} else {
|
} else {
|
||||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
textMessageRow(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1616,6 +1631,56 @@ private extension ContentView {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
||||||
|
let cashuTokens = message.content.extractCashuLinks()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLong && cashuTokens.isEmpty {
|
||||||
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
|
Button(labelKey) {
|
||||||
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .lightning(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
||||||
|
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||||
|
let urlStr = "cashu:\(enc)"
|
||||||
|
PaymentChipView(paymentType: .cashu(urlStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||||
allMessages: [BitchatMessage],
|
allMessages: [BitchatMessage],
|
||||||
privatePeer: PeerID?,
|
privatePeer: PeerID?,
|
||||||
|
|||||||
@@ -40,31 +40,10 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
|
||||||
// show "?" because presence broadcasting is disabled for privacy.
|
|
||||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
level.displayName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: level.displayName, count: count)
|
return rowTitle(label: level.displayName, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||||
// Check precision for bookmarks too
|
|
||||||
let len = geohash.count
|
|
||||||
// Neighborhood=6, Block=7, Building=8+
|
|
||||||
let isHighPrecision = (len >= 6)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
"#\(geohash)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: "#\(geohash)", count: count)
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private enum RegexCache {
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||||
@@ -23,7 +38,7 @@ extension String {
|
|||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = RegexCache.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
@@ -44,19 +59,19 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// lightning: scheme
|
||||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append(s)
|
results.append(s)
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// BOLT11
|
// BOLT11
|
||||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// LNURL bech32
|
||||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
|
|||||||
@@ -41,19 +41,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
if let data = storage[key] {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .itemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
storage[key] = keyData
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
//
|
|
||||||
// BLEServiceCoreTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Focused BLEService tests for packet handling behavior.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct BLEServiceCoreTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func duplicatePacket_isDeduped() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let delegate = PublicCaptureDelegate()
|
|
||||||
ble.delegate = delegate
|
|
||||||
|
|
||||||
let sender = PeerID(str: "1122334455667788")
|
|
||||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
|
|
||||||
let messages = delegate.publicMessagesSnapshot()
|
|
||||||
#expect(messages.count == 1)
|
|
||||||
#expect(messages.first?.content == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func staleBroadcast_isIgnored() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let delegate = PublicCaptureDelegate()
|
|
||||||
ble.delegate = delegate
|
|
||||||
|
|
||||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
|
||||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
|
||||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
||||||
|
|
||||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
|
||||||
#expect(!didReceive)
|
|
||||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeService() -> BLEService {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
|
||||||
BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: Data(hexString: sender.id) ?? Data(),
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: timestamp,
|
|
||||||
payload: Data(content.utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 3
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: nickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
lock.lock()
|
|
||||||
publicMessages.append(message)
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
|
||||||
|
|
||||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return publicMessages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("Hello, world!")
|
service.sendMessage("Hello, world!")
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ struct BLEServiceTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingMessage(incomingMessage)
|
service.simulateIncomingMessage(incomingMessage)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ struct BLEServiceTests {
|
|||||||
service.sendMessage("Test delivery")
|
service.sendMessage("Test delivery")
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ struct BLEServiceTests {
|
|||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(1.0)
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelDeliveryStatusTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel delivery status state machine.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delivery Status Tests
|
|
||||||
|
|
||||||
struct ChatViewModelDeliveryStatusTests {
|
|
||||||
|
|
||||||
// MARK: - Status Transition Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-1"
|
|
||||||
|
|
||||||
// Setup: create a message with .read status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .read(by: "Peer", at: Date())
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: try to downgrade to .delivered
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should remain .read (no downgrade)
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-2"
|
|
||||||
|
|
||||||
// Setup: create a message with .sent status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .sent
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: upgrade to .delivered
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should be .delivered
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .delivered = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-3"
|
|
||||||
|
|
||||||
// Setup: create a message with .delivered status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: upgrade to .read
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
|
||||||
|
|
||||||
// Assert: status should be .read
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Read Receipt Handling
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
|
||||||
let messageID = "test-msg-4"
|
|
||||||
|
|
||||||
// Setup: create a message with .sent status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Test message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Peer",
|
|
||||||
senderPeerID: transport.myPeerID,
|
|
||||||
deliveryStatus: .sent
|
|
||||||
)
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
|
||||||
|
|
||||||
// Action: receive read receipt
|
|
||||||
let receipt = ReadReceipt(
|
|
||||||
originalMessageID: messageID,
|
|
||||||
readerID: peerID,
|
|
||||||
readerNickname: "Peer"
|
|
||||||
)
|
|
||||||
viewModel.didReceiveReadReceipt(receipt)
|
|
||||||
|
|
||||||
// Assert: status should be .read
|
|
||||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .read = currentStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Timeline Status Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let messageID = "public-msg-1"
|
|
||||||
|
|
||||||
// Setup: add a message to public timeline with .sending status
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: viewModel.nickname,
|
|
||||||
content: "Public message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: false,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
viewModel.messages.append(message)
|
|
||||||
|
|
||||||
// Action: update to .sent
|
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
|
||||||
#expect({
|
|
||||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Status Rank Tests (for deduplication)
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func statusRank_orderingIsCorrect() async {
|
|
||||||
// This tests the implicit ordering used in refreshVisibleMessages
|
|
||||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
|
||||||
|
|
||||||
let statuses: [DeliveryStatus] = [
|
|
||||||
.failed(reason: "test"),
|
|
||||||
.sending,
|
|
||||||
.sent,
|
|
||||||
.partiallyDelivered(reached: 1, total: 3),
|
|
||||||
.delivered(to: "B", at: Date()),
|
|
||||||
.read(by: "C", at: Date())
|
|
||||||
]
|
|
||||||
|
|
||||||
// Verify each status has a logical progression
|
|
||||||
// This is more of a documentation test to ensure the ranking logic is understood
|
|
||||||
for (index, status) in statuses.enumerated() {
|
|
||||||
switch status {
|
|
||||||
case .failed: #expect(index == 0)
|
|
||||||
case .sending: #expect(index == 1)
|
|
||||||
case .sent: #expect(index == 2)
|
|
||||||
case .partiallyDelivered: #expect(index == 3)
|
|
||||||
case .delivered: #expect(index == 4)
|
|
||||||
case .read: #expect(index == 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,22 +64,6 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
||||||
// Check MockTransport implementation... it might need update or verification
|
// Check MockTransport implementation... it might need update or verification
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
|
||||||
let peerID = PeerID(str: validHex)
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
|
||||||
#expect({
|
|
||||||
if case .failed = status { return true }
|
|
||||||
return false
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePrivateMessage_storesMessage() async {
|
func handlePrivateMessage_storesMessage() async {
|
||||||
@@ -266,17 +250,10 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||||
let geohash = "u4pruydq"
|
|
||||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
|
||||||
|
|
||||||
LocationChannelManager.shared.select(channel)
|
|
||||||
defer { LocationChannelManager.shared.select(.mesh) }
|
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
|
||||||
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let geohash = "u4pruydq"
|
||||||
|
|
||||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||||
|
|
||||||
var event = NostrEvent(
|
var event = NostrEvent(
|
||||||
pubkey: "pub1",
|
pubkey: "pub1",
|
||||||
@@ -290,119 +267,19 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
viewModel.handleNostrEvent(event)
|
||||||
|
|
||||||
let didAppend = await TestHelpers.waitUntil({
|
// Allow async processing
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
|
||||||
})
|
|
||||||
#expect(didAppend)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Self echo"
|
|
||||||
)
|
|
||||||
event.id = "evt-self"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
// Check timeline
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
// This depends on `handlePublicMessage` being called and updating `messages`
|
||||||
}
|
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
|
||||||
|
// And we are in the correct channel...
|
||||||
@Test @MainActor
|
|
||||||
func handleNostrEvent_skipsBlockedSender() async {
|
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
// Let's verify if the message appears.
|
||||||
let geohash = "u4pruydq"
|
// Note: `handleNostrEvent` logic was refactored.
|
||||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
|
||||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: blockedPubkey,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Blocked"
|
|
||||||
)
|
|
||||||
event.id = "evt-blocked"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
|
||||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Queue Tests
|
|
||||||
|
|
||||||
struct ChatViewModelGeohashQueueTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - GeoDM Tests
|
|
||||||
|
|
||||||
struct ChatViewModelGeoDMTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
|
||||||
let messageID = "pm-1"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
|
||||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
|
||||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
|
||||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
|
||||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelRefactoringTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
|
||||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct ChatViewModelRefactoringTests {
|
|
||||||
|
|
||||||
// Helper to setup the environment
|
|
||||||
@MainActor
|
|
||||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport, identityManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Command Processor Integration "Pinning"
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func command_msg_routesToTransport() async throws {
|
|
||||||
let (viewModel, transport, _) = makePinnedViewModel()
|
|
||||||
|
|
||||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
|
||||||
let peerID = PeerID(str: "0000000000000001")
|
|
||||||
transport.simulateConnect(peerID, nickname: "alice")
|
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didResolve)
|
|
||||||
|
|
||||||
// Action: User types /msg command
|
|
||||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didSend)
|
|
||||||
|
|
||||||
// Assert:
|
|
||||||
// 1. Should NOT go to public transport
|
|
||||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
|
||||||
|
|
||||||
// 2. Should go to private transport logic
|
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
|
||||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
|
||||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func command_block_updatesIdentity() async throws {
|
|
||||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
|
||||||
|
|
||||||
// Setup: Use simulateConnect
|
|
||||||
let peerID = PeerID(str: "0000000000000002")
|
|
||||||
// Mock the fingerprint so the block command finds it
|
|
||||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
|
||||||
transport.simulateConnect(peerID, nickname: "troll")
|
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didResolve)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.sendMessage("/block @troll")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// Verify identity manager was called to block "fingerprint_123"
|
|
||||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
|
||||||
timeout: TestConstants.shortTimeout)
|
|
||||||
#expect(didBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Routing Logic
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
|
||||||
let (viewModel, _, _) = makePinnedViewModel()
|
|
||||||
let senderID = PeerID(str: "sender_1")
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg_1",
|
|
||||||
sender: "bob",
|
|
||||||
content: "Secret",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "me",
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action: Simulate incoming private message
|
|
||||||
viewModel.didReceiveMessage(message)
|
|
||||||
|
|
||||||
// Wait for async processing with proper timeout
|
|
||||||
let found = await TestHelpers.waitUntil(
|
|
||||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
|
||||||
timeout: TestConstants.defaultTimeout
|
|
||||||
)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
|
||||||
let (viewModel, _, _) = makePinnedViewModel()
|
|
||||||
let senderID = PeerID(str: "sender_2")
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg_2",
|
|
||||||
sender: "charlie",
|
|
||||||
content: "Public Hi",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.didReceiveMessage(message)
|
|
||||||
|
|
||||||
// Wait for async processing with proper timeout
|
|
||||||
let found = await TestHelpers.waitUntil(
|
|
||||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
|
||||||
timeout: TestConstants.defaultTimeout
|
|
||||||
)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -114,44 +114,6 @@ struct ChatViewModelSendingTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Handling Tests
|
|
||||||
|
|
||||||
struct ChatViewModelCommandTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_commandsNotSentToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
|
||||||
|
|
||||||
for command in commands {
|
|
||||||
transport.resetRecordings()
|
|
||||||
viewModel.sendMessage(command)
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
#expect(transport.sentPrivateMessages.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timeline Cap Tests
|
|
||||||
|
|
||||||
struct ChatViewModelTimelineCapTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_trimsTimelineToCap() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let total = TransportConfig.meshTimelineCap + 5
|
|
||||||
|
|
||||||
for i in 0..<total {
|
|
||||||
viewModel.sendMessage("cap-msg-\(i)")
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
|
||||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Receiving Tests
|
// MARK: - Message Receiving Tests
|
||||||
|
|
||||||
struct ChatViewModelReceivingTests {
|
struct ChatViewModelReceivingTests {
|
||||||
@@ -202,40 +164,6 @@ struct ChatViewModelReceivingTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Rate Limiting Tests
|
|
||||||
|
|
||||||
struct ChatViewModelRateLimitingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let senderID = PeerID(str: "1122334455667788")
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "rate-\(i)",
|
|
||||||
sender: "Spammer",
|
|
||||||
content: "rate-msg-\(i)",
|
|
||||||
timestamp: now,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
viewModel.handlePublicMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
|
||||||
#expect(burstMessages.count == 5)
|
|
||||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer Connection Tests
|
// MARK: - Peer Connection Tests
|
||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
struct ChatViewModelPeerTests {
|
||||||
@@ -330,94 +258,6 @@ struct ChatViewModelPrivateChatTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Chat Selection Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatSelectionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerA = PeerID(str: "PEER_A")
|
|
||||||
let peerB = PeerID(str: "PEER_B")
|
|
||||||
|
|
||||||
let older = Date().addingTimeInterval(-120)
|
|
||||||
let newer = Date().addingTimeInterval(-30)
|
|
||||||
|
|
||||||
viewModel.privateChats = [
|
|
||||||
peerA: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "a-1",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: older,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerA
|
|
||||||
)
|
|
||||||
],
|
|
||||||
peerB: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "b-1",
|
|
||||||
sender: "B",
|
|
||||||
content: "New",
|
|
||||||
timestamp: newer,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerB
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
|
||||||
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerA = PeerID(str: "PEER_A")
|
|
||||||
let peerB = PeerID(str: "PEER_B")
|
|
||||||
|
|
||||||
let older = Date().addingTimeInterval(-200)
|
|
||||||
let newer = Date().addingTimeInterval(-20)
|
|
||||||
|
|
||||||
viewModel.privateChats = [
|
|
||||||
peerA: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "a-1",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: older,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerA
|
|
||||||
)
|
|
||||||
],
|
|
||||||
peerB: [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "b-1",
|
|
||||||
sender: "B",
|
|
||||||
content: "New",
|
|
||||||
timestamp: newer,
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerB
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
|
||||||
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bluetooth State Tests
|
// MARK: - Bluetooth State Tests
|
||||||
|
|
||||||
struct ChatViewModelBluetoothTests {
|
struct ChatViewModelBluetoothTests {
|
||||||
@@ -469,37 +309,11 @@ struct ChatViewModelPanicTests {
|
|||||||
|
|
||||||
// Set up some state
|
// Set up some state
|
||||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||||
viewModel.messages = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "panic-1",
|
|
||||||
sender: "Tester",
|
|
||||||
content: "Before",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
]
|
|
||||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "pm-1",
|
|
||||||
sender: "Peer",
|
|
||||||
content: "Secret",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: PeerID(str: "PEER1")
|
|
||||||
)
|
|
||||||
]
|
|
||||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
// After panic, emergency disconnect should be called
|
// After panic, emergency disconnect should be called
|
||||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
#expect(viewModel.messages.isEmpty)
|
|
||||||
#expect(viewModel.privateChats.isEmpty)
|
|
||||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelTorTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tor Notification Handler Tests
|
|
||||||
|
|
||||||
struct ChatViewModelTorTests {
|
|
||||||
|
|
||||||
// MARK: - handleTorWillStart Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Precondition: flag should start false
|
|
||||||
#expect(!viewModel.torStatusAnnounced)
|
|
||||||
|
|
||||||
// Action: simulate Tor starting notification
|
|
||||||
viewModel.handleTorWillStart()
|
|
||||||
|
|
||||||
// Wait for Task to complete
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: flag should be set (torEnforced is true in tests)
|
|
||||||
#expect(viewModel.torStatusAnnounced)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: pre-set the flag
|
|
||||||
viewModel.torStatusAnnounced = true
|
|
||||||
|
|
||||||
// Switch to a geohash channel so messages would be visible
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Action: call handler again
|
|
||||||
viewModel.handleTorWillStart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: no new message added (flag was already true)
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorWillRestart Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillRestart_setsPendingFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Precondition
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorWillRestart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
#expect(viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Action: call handler (works regardless of channel)
|
|
||||||
viewModel.handleTorWillRestart()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: flag should be set
|
|
||||||
#expect(viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorDidBecomeReady Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: simulate restart pending state
|
|
||||||
viewModel.torRestartPending = true
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: should clear pending flag
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: not restarting, but initial ready not announced yet
|
|
||||||
viewModel.torRestartPending = false
|
|
||||||
viewModel.torInitialReadyAnnounced = false
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: should set flag (torEnforced is true in tests)
|
|
||||||
#expect(viewModel.torInitialReadyAnnounced)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: already announced initial ready
|
|
||||||
viewModel.torRestartPending = false
|
|
||||||
viewModel.torInitialReadyAnnounced = true
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorDidBecomeReady()
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: no new message
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - handleTorPreferenceChanged Tests
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup: set all flags
|
|
||||||
viewModel.torStatusAnnounced = true
|
|
||||||
viewModel.torInitialReadyAnnounced = true
|
|
||||||
viewModel.torRestartPending = true
|
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Assert: all flags reset
|
|
||||||
#expect(!viewModel.torStatusAnnounced)
|
|
||||||
#expect(!viewModel.torInitialReadyAnnounced)
|
|
||||||
#expect(!viewModel.torRestartPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapNotFoundGrammar() {
|
@Test func slapNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap @system")
|
let result = processor.process("/slap @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
@@ -18,7 +18,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func hugNotFoundGrammar() {
|
@Test func hugNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/hug @system")
|
let result = processor.process("/hug @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
@@ -30,7 +30,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapUsageMessage() {
|
@Test func slapUsageMessage() {
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap")
|
let result = processor.process("/slap")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
|
|||||||
@@ -32,28 +32,29 @@ struct FragmentationTests {
|
|||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||||
let remoteShortID = PeerID(str: "1122334455667788")
|
let remoteShortID = PeerID(str: "1122334455667788")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||||
|
|
||||||
// Use a small fragment size to ensure multiple pieces
|
// Use a small fragment size to ensure multiple pieces
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||||
|
|
||||||
// Shuffle fragments to simulate out-of-order arrival
|
// Shuffle fragments to simulate out-of-order arrival
|
||||||
let shuffled = fragments.shuffled()
|
let shuffled = fragments.shuffled()
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||||
for (i, fragment) in shuffled.enumerated() {
|
for (i, fragment) in shuffled.enumerated() {
|
||||||
if i > 0 {
|
let delay = 5 * Double(i) * 0.001
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Allow async processing
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
}
|
}
|
||||||
@@ -67,26 +68,26 @@ struct FragmentationTests {
|
|||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||||
|
|
||||||
// Duplicate one fragment
|
// Duplicate one fragment
|
||||||
if let dup = frags.first {
|
if let dup = frags.first {
|
||||||
frags.insert(dup, at: 1)
|
frags.insert(dup, at: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
|
||||||
for (i, fragment) in frags.enumerated() {
|
for (i, fragment) in frags.enumerated() {
|
||||||
if i > 0 {
|
let delay = 5 * Double(i) * 0.001
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Allow async processing
|
||||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
@@ -134,7 +135,7 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
try await sleep(1.0)
|
||||||
|
|
||||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
#expect(message.content.hasPrefix("[file]"))
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
@@ -195,128 +196,12 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension FragmentationTests {
|
extension FragmentationTests {
|
||||||
/// Thread-safe delegate that supports awaiting message delivery
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
private let lock = NSLock()
|
var receivedMessages: [BitchatMessage] = []
|
||||||
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
|
||||||
private var _receivedMessages: [BitchatMessage] = []
|
|
||||||
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var expectedPublicMessageCount: Int = 0
|
|
||||||
private var expectedReceivedMessageCount: Int = 0
|
|
||||||
|
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return _publicMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
var receivedMessages: [BitchatMessage] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return _receivedMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
lock.lock()
|
receivedMessages.append(message)
|
||||||
_receivedMessages.append(message)
|
|
||||||
let count = _receivedMessages.count
|
|
||||||
let expected = expectedReceivedMessageCount
|
|
||||||
let continuation = receivedMessageContinuation
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
if count >= expected, let cont = continuation {
|
|
||||||
lock.lock()
|
|
||||||
receivedMessageContinuation = nil
|
|
||||||
lock.unlock()
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
||||||
lock.lock()
|
|
||||||
_publicMessages.append((peerID, nickname, content))
|
|
||||||
let count = _publicMessages.count
|
|
||||||
let expected = expectedPublicMessageCount
|
|
||||||
let continuation = publicMessageContinuation
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
if count >= expected, let cont = continuation {
|
|
||||||
lock.lock()
|
|
||||||
publicMessageContinuation = nil
|
|
||||||
lock.unlock()
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for the specified number of public messages to be received
|
|
||||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
||||||
lock.lock()
|
|
||||||
if _publicMessages.count >= count {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expectedPublicMessageCount = count
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
self.lock.lock()
|
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
|
||||||
// where message arrives between initial check and continuation install
|
|
||||||
if self._publicMessages.count >= count {
|
|
||||||
self.lock.unlock()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.publicMessageContinuation = continuation
|
|
||||||
self.lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(for: timeout)
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for the specified number of received messages
|
|
||||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
||||||
lock.lock()
|
|
||||||
if _receivedMessages.count >= count {
|
|
||||||
lock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expectedReceivedMessageCount = count
|
|
||||||
lock.unlock()
|
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
self.lock.lock()
|
|
||||||
// Recheck count after acquiring lock to avoid race condition
|
|
||||||
// where message arrives between initial check and continuation install
|
|
||||||
if self._receivedMessages.count >= count {
|
|
||||||
self.lock.unlock()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.receivedMessageContinuation = continuation
|
|
||||||
self.lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(for: timeout)
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
@@ -324,6 +209,9 @@ extension FragmentationTests {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
|
publicMessages.append((peerID, nickname, content))
|
||||||
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,567 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for the Geohash Presence (Kind 20001) feature.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - NostrProtocol Presence Event Tests
|
|
||||||
|
|
||||||
struct NostrProtocolPresenceTests {
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasCorrectKind() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
||||||
#expect(event.kind == 20001)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasEmptyContent() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.content == "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Should have exactly one tag: ["g", geohash]
|
|
||||||
#expect(event.tags.count == 1)
|
|
||||||
#expect(event.tags[0] == ["g", "u4pruydq"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_noNicknameTag() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Should NOT contain nickname tag
|
|
||||||
let hasNicknameTag = event.tags.contains { $0.first == "n" }
|
|
||||||
#expect(!hasNicknameTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_usesSenderPubkey() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.pubkey == identity.publicKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_isSigned() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: "u4pruydq",
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(event.sig != nil && !event.sig!.isEmpty)
|
|
||||||
#expect(!event.id.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func createGeohashPresenceEvent_differentGeohashes() throws {
|
|
||||||
let identity = try makeTestIdentity()
|
|
||||||
|
|
||||||
let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity)
|
|
||||||
let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity)
|
|
||||||
let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity)
|
|
||||||
|
|
||||||
#expect(event1.tags[0][1] == "87")
|
|
||||||
#expect(event2.tags[0][1] == "87yw")
|
|
||||||
#expect(event3.tags[0][1] == "87yw7")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper
|
|
||||||
|
|
||||||
private func makeTestIdentity() throws -> NostrIdentity {
|
|
||||||
// Generate a fresh test identity
|
|
||||||
return try NostrIdentity.generate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NostrFilter Presence Tests
|
|
||||||
|
|
||||||
struct NostrFilterPresenceTests {
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_includesBothKinds() {
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
||||||
|
|
||||||
#expect(filter.kinds?.contains(20000) == true)
|
|
||||||
#expect(filter.kinds?.contains(20001) == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_hasLimit1000() {
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
||||||
|
|
||||||
#expect(filter.limit == 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashEphemeral_respectsSinceParameter() {
|
|
||||||
let since = Date(timeIntervalSince1970: 1700000000)
|
|
||||||
let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since)
|
|
||||||
|
|
||||||
#expect(filter.since == 1700000000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ChatViewModel Presence Handling Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ChatViewModelPresenceHandlingTests {
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
// Set up the channel
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create a presence event (kind 20001)
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "presence_evt_1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// Handle the event
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Participant should be recorded
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
let initialMessageCount = viewModel.messages.count
|
|
||||||
|
|
||||||
// Create a presence event (kind 20001)
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "presence_evt_2"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Message count should NOT increase
|
|
||||||
#expect(viewModel.messages.count == initialMessageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create a chat event (kind 20000) - NOT presence
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Hello world"
|
|
||||||
)
|
|
||||||
event.id = "chat_evt_1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Chat messages should also update participant count (not just presence)
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func presenceEvent_hasDifferentKindThanChat() {
|
|
||||||
// Verify the two event kinds are distinct
|
|
||||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
||||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|
|
||||||
#expect(presenceKind != chatKind)
|
|
||||||
#expect(presenceKind == 20001)
|
|
||||||
#expect(chatKind == 20000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
// Create presence event
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "subscribe_presence_evt"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// subscribeNostrEvent should accept kind 20001
|
|
||||||
viewModel.subscribeNostrEvent(event)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Should record participant
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let activeGeohash = "u4pruydq"
|
|
||||||
let otherGeohash = "87yw7"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
|
||||||
|
|
||||||
// Create presence event for a DIFFERENT geohash
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", otherGeohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
event.id = "other_geohash_presence"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
// Use subscribeNostrEvent with geohash parameter
|
|
||||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
// Should record for the other geohash
|
|
||||||
let count = viewModel.geohashParticipantCount(for: otherGeohash)
|
|
||||||
#expect(count >= 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test Helper
|
|
||||||
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Presence Privacy Tests
|
|
||||||
|
|
||||||
struct GeohashPresencePrivacyTests {
|
|
||||||
|
|
||||||
@Test func allowedPrecisions_onlyLowPrecision() {
|
|
||||||
// The allowed precisions for presence broadcasting should be:
|
|
||||||
// Region (2), Province (4), City (5)
|
|
||||||
// NOT Neighborhood (6), Block (7), Building (8+)
|
|
||||||
|
|
||||||
let regionPrecision = GeohashChannelLevel.region.precision
|
|
||||||
let provincePrecision = GeohashChannelLevel.province.precision
|
|
||||||
let cityPrecision = GeohashChannelLevel.city.precision
|
|
||||||
let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision
|
|
||||||
let blockPrecision = GeohashChannelLevel.block.precision
|
|
||||||
let buildingPrecision = GeohashChannelLevel.building.precision
|
|
||||||
|
|
||||||
#expect(regionPrecision == 2)
|
|
||||||
#expect(provincePrecision == 4)
|
|
||||||
#expect(cityPrecision == 5)
|
|
||||||
#expect(neighborhoodPrecision == 6)
|
|
||||||
#expect(blockPrecision == 7)
|
|
||||||
#expect(buildingPrecision == 8)
|
|
||||||
|
|
||||||
// High precision channels should NOT receive presence broadcasts
|
|
||||||
#expect(neighborhoodPrecision > 5)
|
|
||||||
#expect(blockPrecision > 5)
|
|
||||||
#expect(buildingPrecision > 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geohashLengthDeterminesPrecision() {
|
|
||||||
// Verify geohash length maps to expected precision
|
|
||||||
#expect("87".count == GeohashChannelLevel.region.precision)
|
|
||||||
#expect("87yw".count == GeohashChannelLevel.province.precision)
|
|
||||||
#expect("87yw7".count == GeohashChannelLevel.city.precision)
|
|
||||||
#expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision)
|
|
||||||
#expect("87yw7tc".count == GeohashChannelLevel.block.precision)
|
|
||||||
#expect("87yw7tcx".count == GeohashChannelLevel.building.precision)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func highPrecisionGeohash_isPrivacySensitive() {
|
|
||||||
// Helper to check if a geohash is "high precision" (privacy sensitive)
|
|
||||||
func isHighPrecision(_ geohash: String) -> Bool {
|
|
||||||
geohash.count >= 6
|
|
||||||
}
|
|
||||||
|
|
||||||
// Low precision - OK to broadcast presence
|
|
||||||
#expect(!isHighPrecision("87")) // region
|
|
||||||
#expect(!isHighPrecision("87yw")) // province
|
|
||||||
#expect(!isHighPrecision("87yw7")) // city
|
|
||||||
|
|
||||||
// High precision - should NOT broadcast presence
|
|
||||||
#expect(isHighPrecision("87yw7t")) // neighborhood
|
|
||||||
#expect(isHighPrecision("87yw7tc")) // block
|
|
||||||
#expect(isHighPrecision("87yw7tcx")) // building
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display Logic Tests
|
|
||||||
|
|
||||||
struct LocationChannelsDisplayLogicTests {
|
|
||||||
|
|
||||||
@Test func displayLogic_highPrecisionZeroCount_showsUnknown() {
|
|
||||||
// Test the logic that determines "?" vs actual count
|
|
||||||
// High precision + count 0 = "?"
|
|
||||||
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .neighborhood,
|
|
||||||
count: 0
|
|
||||||
)
|
|
||||||
#expect(shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_highPrecisionNonZeroCount_showsActual() {
|
|
||||||
// High precision + count > 0 = show actual
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .neighborhood,
|
|
||||||
count: 5
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_lowPrecisionZeroCount_showsActual() {
|
|
||||||
// Low precision + count 0 = show "0" (not "?")
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .city,
|
|
||||||
count: 0
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {
|
|
||||||
// Low precision + count > 0 = show actual
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(
|
|
||||||
level: .region,
|
|
||||||
count: 10
|
|
||||||
)
|
|
||||||
#expect(!shouldShowUnknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_allHighPrecisionLevels() {
|
|
||||||
// All high precision levels with 0 should show "?"
|
|
||||||
let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]
|
|
||||||
|
|
||||||
for level in highPrecisionLevels {
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
||||||
#expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_allLowPrecisionLevels() {
|
|
||||||
// All low precision levels with 0 should show actual count
|
|
||||||
let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]
|
|
||||||
|
|
||||||
for level in lowPrecisionLevels {
|
|
||||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
||||||
#expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_bookmarkHighPrecision() {
|
|
||||||
// Bookmarks use geohash length to determine precision
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7
|
|
||||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func displayLogic_bookmarkLowPrecision() {
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4
|
|
||||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers (mirror the logic from LocationChannelsSheet)
|
|
||||||
|
|
||||||
private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {
|
|
||||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
||||||
return isHighPrecision && count == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {
|
|
||||||
let isHighPrecision = (geohash.count >= 6)
|
|
||||||
return isHighPrecision && count == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Event Kind Tests
|
|
||||||
|
|
||||||
struct NostrEventKindTests {
|
|
||||||
|
|
||||||
@Test func eventKind_geohashPresence_is20001() {
|
|
||||||
#expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eventKind_ephemeralEvent_is20000() {
|
|
||||||
#expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eventKind_presenceIsEphemeral() {
|
|
||||||
// Both 20000 and 20001 are in the ephemeral range (20000-29999)
|
|
||||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
||||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|
|
||||||
#expect(presenceKind >= 20000 && presenceKind < 30000)
|
|
||||||
#expect(chatKind >= 20000 && chatKind < 30000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Participant Tracker Presence Integration Tests
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ParticipantTrackerPresenceTests {
|
|
||||||
|
|
||||||
@Test func recordParticipant_fromPresenceEvent_countsParticipant() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
let geohash = "87yw7"
|
|
||||||
tracker.setActiveGeohash(geohash)
|
|
||||||
|
|
||||||
// Simulate recording from a presence event
|
|
||||||
tracker.recordParticipant(pubkeyHex: "presence_user_1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_multiplePresenceEvents_countsUnique() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
let geohash = "87yw7"
|
|
||||||
tracker.setActiveGeohash(geohash)
|
|
||||||
|
|
||||||
// Multiple presence events from same user = 1 participant
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 1)
|
|
||||||
|
|
||||||
// Different user = 2 participants
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user_b")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: geohash) == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_nonActiveGeohash_stillCounts() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
// Active geohash is different from where we're recording
|
|
||||||
tracker.setActiveGeohash("active_gh")
|
|
||||||
|
|
||||||
// Record to a non-active geohash (like when sampling nearby channels)
|
|
||||||
tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "other_gh") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "active_gh") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = PresenceTestParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.setActiveGeohash("active_gh")
|
|
||||||
|
|
||||||
var changeCount = 0
|
|
||||||
let cancellable = tracker.objectWillChange.sink { _ in
|
|
||||||
changeCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record to non-active geohash
|
|
||||||
tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh")
|
|
||||||
|
|
||||||
// Should fire objectWillChange even for non-active geohash
|
|
||||||
#expect(changeCount >= 1)
|
|
||||||
|
|
||||||
_ = cancellable // Keep alive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mock for Participant Context (Presence Tests)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class PresenceTestParticipantContext: GeohashParticipantContext {
|
|
||||||
var blockedPubkeys: Set<String> = []
|
|
||||||
var nicknameMap: [String: String] = [:]
|
|
||||||
var selfPubkey: String?
|
|
||||||
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
|
||||||
if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {
|
|
||||||
return "me#\(suffix)"
|
|
||||||
}
|
|
||||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
|
||||||
return "\(nick)#\(suffix)"
|
|
||||||
}
|
|
||||||
return "anon#\(suffix)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
||||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,14 +7,12 @@ struct GossipSyncManagerTests {
|
|||||||
private let myPeerID = PeerID(str: "0102030405060708")
|
private let myPeerID = PeerID(str: "0102030405060708")
|
||||||
|
|
||||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
try await confirmation("sync request sent") { sent in
|
try await confirmation("sync request sent") { sent in
|
||||||
delegate.onSend = {
|
delegate.onSend = {
|
||||||
delegate.onSend = nil
|
|
||||||
sent()
|
sent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ struct GossipSyncManagerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
try await sleep(0.002)
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||||
@@ -49,8 +47,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.stalePeerCleanupIntervalSeconds = 0
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let peerHex = "0011223344556677"
|
let peerHex = "0011223344556677"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
@@ -95,8 +92,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
config.maxMessageAgeSeconds = 100
|
config.maxMessageAgeSeconds = 100
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let peerHex = "8899aabbccddeeff"
|
let peerHex = "8899aabbccddeeff"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||||
@@ -140,8 +136,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.fileTransferSyncIntervalSeconds = 1
|
config.fileTransferSyncIntervalSeconds = 1
|
||||||
config.maintenanceIntervalSeconds = 0
|
config.maintenanceIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
@@ -211,8 +206,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
@@ -246,7 +240,7 @@ struct GossipSyncManagerTests {
|
|||||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||||
manager.handleRequestSync(from: peer, request: request)
|
manager.handleRequestSync(from: peer, request: request)
|
||||||
|
|
||||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
try await sleep(0.01)
|
||||||
let sentPackets = delegate.packets
|
let sentPackets = delegate.packets
|
||||||
#expect(sentPackets.count == 1)
|
#expect(sentPackets.count == 1)
|
||||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||||
@@ -274,8 +268,4 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
|||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
packet
|
packet
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConnectedPeers() -> [PeerID] {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ struct InputValidatorTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Validation Tests
|
// MARK: - Timestamp Validation Tests
|
||||||
// BCH-01-011: Window reduced from ±1 hour to ±5 minutes
|
|
||||||
|
|
||||||
@Test func currentTimestampIsValid() throws {
|
@Test func currentTimestampIsValid() throws {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -139,48 +138,31 @@ struct InputValidatorTests {
|
|||||||
#expect(result == true)
|
#expect(result == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func timestampWithinFiveMinutesIsValid() throws {
|
@Test func timestampWithinOneHourIsValid() throws {
|
||||||
// 2 minutes ago should be valid (within 5-minute window)
|
|
||||||
let twoMinutesAgo = Date().addingTimeInterval(-2 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(twoMinutesAgo)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampThirtyMinutesAgoIsInvalid() throws {
|
|
||||||
// BCH-01-011: 30 minutes is now outside the 5-minute window
|
|
||||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
||||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTenMinutesAgoIsInvalid() throws {
|
|
||||||
// 10 minutes is outside the 5-minute window
|
|
||||||
let tenMinutesAgo = Date().addingTimeInterval(-10 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(tenMinutesAgo)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTenMinutesInFutureIsInvalid() throws {
|
|
||||||
// 10 minutes in future is outside the 5-minute window
|
|
||||||
let tenMinutesFromNow = Date().addingTimeInterval(10 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(tenMinutesFromNow)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampAtFiveMinuteBoundaryIsValid() throws {
|
|
||||||
// Just slightly within the five-minute window (299 seconds)
|
|
||||||
let almostFiveMinutesAgo = Date().addingTimeInterval(-299)
|
|
||||||
let result = InputValidator.validateTimestamp(almostFiveMinutesAgo)
|
|
||||||
#expect(result == true)
|
#expect(result == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func timestampJustOutsideFiveMinuteWindowIsInvalid() throws {
|
@Test func timestampTwoHoursAgoIsInvalid() throws {
|
||||||
// Just outside the five-minute window (301 seconds)
|
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
|
||||||
let justOverFiveMinutesAgo = Date().addingTimeInterval(-301)
|
let result = InputValidator.validateTimestamp(twoHoursAgo)
|
||||||
let result = InputValidator.validateTimestamp(justOverFiveMinutesAgo)
|
|
||||||
#expect(result == false)
|
#expect(result == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func timestampTwoHoursInFutureIsInvalid() throws {
|
||||||
|
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
|
||||||
|
let result = InputValidator.validateTimestamp(twoHoursFromNow)
|
||||||
|
#expect(result == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func timestampAtOneHourBoundaryIsValid() throws {
|
||||||
|
// Just slightly within the one-hour window
|
||||||
|
let almostOneHourAgo = Date().addingTimeInterval(-3599)
|
||||||
|
let result = InputValidator.validateTimestamp(almostOneHourAgo)
|
||||||
|
#expect(result == true)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Edge Cases
|
// MARK: - Edge Cases
|
||||||
|
|
||||||
@Test func singleCharacterStringIsAccepted() throws {
|
@Test func singleCharacterStringIsAccepted() throws {
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
//
|
|
||||||
// KeychainErrorHandlingTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
// BCH-01-009: Tests for proper keychain error classification and handling
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct KeychainErrorHandlingTests {
|
|
||||||
|
|
||||||
// MARK: - Error Classification Tests
|
|
||||||
|
|
||||||
@Test func keychainReadResult_successIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.success(Data([1, 2, 3]))
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.itemNotFound
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_deviceLockedIsRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.deviceLocked
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_authenticationFailedIsRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.authenticationFailed
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainReadResult_accessDeniedIsNotRecoverable() throws {
|
|
||||||
let result = KeychainReadResult.accessDenied
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_successIsNotRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.success
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_duplicateItemIsRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.duplicateItem
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_deviceLockedIsRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.deviceLocked
|
|
||||||
#expect(result.isRecoverableError == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func keychainSaveResult_storageFullIsNotRecoverable() throws {
|
|
||||||
let result = KeychainSaveResult.storageFull
|
|
||||||
#expect(result.isRecoverableError == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mock Keychain Error Simulation Tests
|
|
||||||
|
|
||||||
@Test func mockKeychain_canSimulateReadErrors() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Simulate access denied error
|
|
||||||
keychain.simulatedReadError = .accessDenied
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "testKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .accessDenied:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected accessDenied, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_canSimulateSaveErrors() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Simulate storage full error
|
|
||||||
keychain.simulatedSaveError = .storageFull
|
|
||||||
let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .storageFull:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected storageFull, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_returnsItemNotFoundForMissingKey() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected itemNotFound, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_returnsSuccessForExistingKey() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let testData = Data([1, 2, 3, 4, 5])
|
|
||||||
|
|
||||||
// First save the key
|
|
||||||
_ = keychain.saveIdentityKey(testData, forKey: "existingKey")
|
|
||||||
|
|
||||||
// Now read it back
|
|
||||||
let result = keychain.getIdentityKeyWithResult(forKey: "existingKey")
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let data):
|
|
||||||
#expect(data == testData)
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected success, got \(result)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mockKeychain_saveWithResultStoresData() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let testData = Data([10, 20, 30])
|
|
||||||
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
// Verify data was stored
|
|
||||||
let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey")
|
|
||||||
switch readResult {
|
|
||||||
case .success(let data):
|
|
||||||
#expect(data == testData)
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected to read back saved data")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected save success, got \(saveResult)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NoiseEncryptionService Integration Tests
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create service with empty keychain - should generate new identity
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have generated and saved keys
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
#expect(service.getSigningPublicKeyData().count == 32)
|
|
||||||
|
|
||||||
// Keys should be persisted
|
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
|
||||||
switch noiseKeyResult {
|
|
||||||
case .success:
|
|
||||||
// Expected - key was saved
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw KeychainTestError("Expected noise key to be saved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_loadsExistingIdentity() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create first service to generate identity
|
|
||||||
let service1 = NoiseEncryptionService(keychain: keychain)
|
|
||||||
let originalPublicKey = service1.getStaticPublicKeyData()
|
|
||||||
let originalSigningKey = service1.getSigningPublicKeyData()
|
|
||||||
|
|
||||||
// Create second service - should load same identity
|
|
||||||
let service2 = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
|
|
||||||
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
keychain.simulatedReadError = .accessDenied
|
|
||||||
|
|
||||||
// Service should still initialize with ephemeral key
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have an identity (ephemeral)
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
#expect(service.getSigningPublicKeyData().count == 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
keychain.simulatedReadError = .deviceLocked
|
|
||||||
|
|
||||||
// Service should still initialize with ephemeral key
|
|
||||||
let service = NoiseEncryptionService(keychain: keychain)
|
|
||||||
|
|
||||||
// Should have an identity (ephemeral)
|
|
||||||
#expect(service.getStaticPublicKeyData().count == 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper error type for tests
|
|
||||||
private struct KeychainTestError: Error, CustomStringConvertible {
|
|
||||||
let message: String
|
|
||||||
init(_ message: String) { self.message = message }
|
|
||||||
var description: String { message }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
//import Foundation
|
||||||
|
//import XCTest
|
||||||
|
//@testable import bitchat
|
||||||
|
//
|
||||||
|
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||||
|
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||||
|
//
|
||||||
|
//final class LocalizationCatalogTests: XCTestCase {
|
||||||
|
// // Ensures every app locale includes exactly the same keys as Base.
|
||||||
|
// func testAppCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies format placeholders stay consistent across app locales.
|
||||||
|
// func testAppCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Guards a core set of app strings from going empty per locale.
|
||||||
|
// func testAppPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().app
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures every share extension locale matches Base key coverage.
|
||||||
|
// func testShareExtensionCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies share extension placeholders align across locales.
|
||||||
|
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Confirms critical share extension strings remain non-empty per locale.
|
||||||
|
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Validates that configured locales contain expected string values.
|
||||||
|
// func testLocalizationExpectedValues() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let expectedValues = config.expectedValues else {
|
||||||
|
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Loop through each locale to test
|
||||||
|
// for locale in testLocales {
|
||||||
|
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||||
|
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Test each expected key/value pair for this locale
|
||||||
|
// for (key, expectedValue) in localeExpectedValues {
|
||||||
|
// if config.app.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||||
|
// } else if config.shareExtension.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures configured test locales are present and complete.
|
||||||
|
// func testConfiguredLocalesCompleteness() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// let baseLocale = appContext.baseLocale
|
||||||
|
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
//
|
||||||
|
// for locale in testLocales {
|
||||||
|
// // Skip base locale comparison with itself
|
||||||
|
// if locale == baseLocale { continue }
|
||||||
|
//
|
||||||
|
// // Verify locale is present in both catalogs
|
||||||
|
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||||
|
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||||
|
//
|
||||||
|
// // Verify locale has same number of keys as base locale
|
||||||
|
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||||
|
//
|
||||||
|
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Assertions
|
||||||
|
//
|
||||||
|
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// guard locale != baseLocale else { continue }
|
||||||
|
// for key in baseSignatures.keys.sorted() {
|
||||||
|
// guard let baseMap = baseSignatures[key] else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// guard let localeMap = localeSignatures[key] else {
|
||||||
|
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for path in baseMap.keys.sorted() {
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for (localePath, localeTokens) in localeMap {
|
||||||
|
// guard baseMap[localePath] == nil else { continue }
|
||||||
|
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||||
|
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeTokens)
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// for key in keys {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// for locale in context.locales.sorted() {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// for segment in segments {
|
||||||
|
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // For simple strings (non-pluralized)
|
||||||
|
// if let actualValue = unit.value {
|
||||||
|
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||||
|
// } else {
|
||||||
|
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Loading
|
||||||
|
//
|
||||||
|
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||||
|
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||||
|
// let locales = catalog.locales
|
||||||
|
// let baseLocale = catalog.sourceLanguage
|
||||||
|
// var keysByLocale: [String: Set<String>] = [:]
|
||||||
|
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||||
|
//
|
||||||
|
// for locale in locales {
|
||||||
|
// var localeKeys: Set<String> = []
|
||||||
|
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||||
|
// for (key, entry) in catalog.strings {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// localeKeys.insert(key)
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// var pathMap: [String: [String]] = [:]
|
||||||
|
// for segment in segments {
|
||||||
|
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||||
|
// }
|
||||||
|
// localePlaceholders[key] = pathMap
|
||||||
|
// }
|
||||||
|
// keysByLocale[locale] = localeKeys
|
||||||
|
// placeholderSignature[locale] = localePlaceholders
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||||
|
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||||
|
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Regression Tests
|
||||||
|
//
|
||||||
|
// /// Helper method to access the app bundle for localization testing.
|
||||||
|
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||||
|
// /// the test bundle, preventing false positives where localization keys are
|
||||||
|
// /// returned instead of translated strings.
|
||||||
|
// private func appBundle() -> Bundle {
|
||||||
|
// Bundle(for: ChatViewModel.self)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /// Tests to prevent regression of the pluralization format string issue
|
||||||
|
// /// These tests ensure that String(localized:) properly handles
|
||||||
|
// /// pluralized format strings with %#@variable@ syntax
|
||||||
|
// func testPluralizedFormatStringsDoNotCrash() {
|
||||||
|
// // These are the exact calls that were causing runtime errors
|
||||||
|
//
|
||||||
|
// // Test 1: content.accessibility.people_count with Int argument
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Accessibility label announcing number of people in header"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||||
|
// }(), "People count localization should not throw")
|
||||||
|
//
|
||||||
|
// // Test 2: location_notes.header with String and Int arguments
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Header displaying the geohash and localized note count"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||||
|
// }(), "Location notes header localization should not throw")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testFormatStringArgumentMatching() {
|
||||||
|
// // Verify that the format strings properly handle their expected arguments
|
||||||
|
//
|
||||||
|
// // People count expects: %#@people@ format with Int
|
||||||
|
// let peopleResult = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Should not show format specifiers in the base string
|
||||||
|
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||||
|
//
|
||||||
|
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||||
|
// let notesResult = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testPluralizedStringsWithArguments() {
|
||||||
|
// // Test the pluralized strings with actual format arguments
|
||||||
|
//
|
||||||
|
// // Test people count with different values
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Just verify it doesn't crash and returns a reasonable string
|
||||||
|
// XCTAssertFalse(result.isEmpty,
|
||||||
|
// "People count should return non-empty string for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain the count number for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationNotesHeaderWithArguments() {
|
||||||
|
// let geohash = "abc123"
|
||||||
|
// let testCases = [0, 1, 2, 10]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// geohash, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(geohash),
|
||||||
|
// "Result should contain geohash for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationChannelsRowTitleWithArguments() {
|
||||||
|
// let label = "test-channel"
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// label, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(label),
|
||||||
|
// "Result should contain label for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testNoArgumentsLocalization() {
|
||||||
|
// // Test that strings without arguments still work
|
||||||
|
// let result = String(
|
||||||
|
// localized: "common.ok",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "OK button text"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocalizationEdgeCases() {
|
||||||
|
// // Test edge cases that might cause issues
|
||||||
|
//
|
||||||
|
// // Large numbers
|
||||||
|
// let largeNumberResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 1000000
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||||
|
//
|
||||||
|
// // Zero
|
||||||
|
// let zeroResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 0
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||||
|
//
|
||||||
|
// // Empty geohash (edge case)
|
||||||
|
// let emptyGeohashResult = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// "", 1
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//// MARK: - Helpers
|
||||||
|
//
|
||||||
|
//private struct CatalogContext {
|
||||||
|
// let catalog: StringCatalog
|
||||||
|
// let locales: [String]
|
||||||
|
// let baseLocale: String
|
||||||
|
// let keysByLocale: [String: Set<String>]
|
||||||
|
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct StringCatalog: Decodable {
|
||||||
|
// let sourceLanguage: String
|
||||||
|
// let strings: [String: CatalogEntry]
|
||||||
|
//
|
||||||
|
// var locales: [String] {
|
||||||
|
// var localeSet: Set<String> = []
|
||||||
|
// for entry in strings.values {
|
||||||
|
// localeSet.formUnion(entry.localizations.keys)
|
||||||
|
// }
|
||||||
|
// return localeSet.sorted()
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogEntry: Decodable {
|
||||||
|
// let localizations: [String: CatalogLocalization]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogLocalization: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogStringUnit: Decodable {
|
||||||
|
// let state: String
|
||||||
|
// let value: String?
|
||||||
|
// let variations: CatalogVariations?
|
||||||
|
// let comment: String?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariations: Decodable {
|
||||||
|
// let plural: [String: [String: CatalogVariationValue]]?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariationValue: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct Segment {
|
||||||
|
// let components: [String]
|
||||||
|
// let value: String
|
||||||
|
//
|
||||||
|
// var path: String {
|
||||||
|
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||||
|
// var segments: [Segment] = []
|
||||||
|
// if let value = unit.value {
|
||||||
|
// segments.append(Segment(components: prefix, value: value))
|
||||||
|
// } else if prefix.isEmpty {
|
||||||
|
// segments.append(Segment(components: [], value: ""))
|
||||||
|
// }
|
||||||
|
// if let plural = unit.variations?.plural {
|
||||||
|
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// if let nested = variation.stringUnit {
|
||||||
|
// var nextPrefix = prefix
|
||||||
|
// nextPrefix.append("plural")
|
||||||
|
// nextPrefix.append(variable)
|
||||||
|
// nextPrefix.append(category)
|
||||||
|
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return segments
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||||
|
// tokens.sorted()
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||||
|
// let parts = localePath.split(separator: ".")
|
||||||
|
// guard parts.count == 3, parts.first == "plural" else {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// let variable = parts[1]
|
||||||
|
// let otherKey = "plural.\(variable).other"
|
||||||
|
// if baseMap[otherKey] != nil {
|
||||||
|
// return otherKey
|
||||||
|
// }
|
||||||
|
// let oneKey = "plural.\(variable).one"
|
||||||
|
// if baseMap[oneKey] != nil {
|
||||||
|
// return oneKey
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private let placeholderRegex: NSRegularExpression = {
|
||||||
|
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||||
|
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
//}()
|
||||||
|
//
|
||||||
|
//private func placeholders(in string: String) -> [String] {
|
||||||
|
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||||
|
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||||
|
// var tokens: [String] = []
|
||||||
|
// for match in matches {
|
||||||
|
// if let range = Range(match.range, in: string) {
|
||||||
|
// let token = String(string[range])
|
||||||
|
// if token == "%%" { continue }
|
||||||
|
// tokens.append(token)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return tokens
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct PrimaryKeyConfig: Decodable {
|
||||||
|
// let app: [String]
|
||||||
|
// let shareExtension: [String]
|
||||||
|
// let expectedValues: [String: [String: String]]?
|
||||||
|
// let testLocales: [String]?
|
||||||
|
//}
|
||||||
@@ -13,7 +13,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private var blockedFingerprints: Set<String> = []
|
private var blockedFingerprints: Set<String> = []
|
||||||
private var blockedNostrPubkeys: Set<String> = []
|
private var blockedNostrPubkeys: Set<String> = []
|
||||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
|
||||||
|
|
||||||
init(_ keychain: KeychainManagerProtocol) {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
@@ -26,7 +25,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
func forceSave() {}
|
func forceSave() {}
|
||||||
|
|
||||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||||
socialIdentities[fingerprint]
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||||
@@ -35,14 +34,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
[]
|
[]
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {}
|
||||||
socialIdentities[identity.fingerprint] = identity
|
|
||||||
if identity.isBlocked {
|
|
||||||
blockedFingerprints.insert(identity.fingerprint)
|
|
||||||
} else {
|
|
||||||
blockedFingerprints.remove(identity.fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFavorites() -> Set<String> {
|
func getFavorites() -> Set<String> {
|
||||||
Set()
|
Set()
|
||||||
@@ -55,25 +47,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isBlocked(fingerprint: String) -> Bool {
|
func isBlocked(fingerprint: String) -> Bool {
|
||||||
blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true
|
blockedFingerprints.contains(fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||||
if var identity = socialIdentities[fingerprint] {
|
|
||||||
identity.isBlocked = isBlocked
|
|
||||||
socialIdentities[fingerprint] = identity
|
|
||||||
} else {
|
|
||||||
let identity = SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: "",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: isBlocked,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
socialIdentities[fingerprint] = identity
|
|
||||||
}
|
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
blockedFingerprints.insert(fingerprint)
|
blockedFingerprints.insert(fingerprint)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [String: Data]] = [:]
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
|
|
||||||
// BCH-01-009: Configurable error simulation for testing
|
|
||||||
var simulatedReadError: KeychainReadResult?
|
|
||||||
var simulatedSaveError: KeychainSaveResult?
|
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
return true
|
return true
|
||||||
@@ -49,25 +45,6 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
if let simulated = simulatedReadError {
|
|
||||||
return simulated
|
|
||||||
}
|
|
||||||
if let data = storage[key] {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .itemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
if let simulated = simulatedSaveError {
|
|
||||||
return simulated
|
|
||||||
}
|
|
||||||
storage[key] = keyData
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
@@ -99,10 +76,6 @@ final class TrackingMockKeychain: KeychainManagerProtocol {
|
|||||||
private var _secureClearDataCallCount = 0
|
private var _secureClearDataCallCount = 0
|
||||||
private var _secureClearStringCallCount = 0
|
private var _secureClearStringCallCount = 0
|
||||||
|
|
||||||
// BCH-01-009: Configurable error simulation for testing
|
|
||||||
var simulatedReadError: KeychainReadResult?
|
|
||||||
var simulatedSaveError: KeychainSaveResult?
|
|
||||||
|
|
||||||
var secureClearDataCallCount: Int {
|
var secureClearDataCallCount: Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
@@ -164,25 +137,6 @@ final class TrackingMockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
if let simulated = simulatedReadError {
|
|
||||||
return simulated
|
|
||||||
}
|
|
||||||
if let data = storage[key] {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .itemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
if let simulated = simulatedSaveError {
|
|
||||||
return simulated
|
|
||||||
}
|
|
||||||
storage[key] = keyData
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
if serviceStorage[service] == nil {
|
if serviceStorage[service] == nil {
|
||||||
serviceStorage[service] = [:]
|
serviceStorage[service] = [:]
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ final class MockTransport: Transport {
|
|||||||
}
|
}
|
||||||
delegate?.didConnectToPeer(peerID)
|
delegate?.didConnectToPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
publishPeerSnapshots()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulates a peer disconnecting
|
/// Simulates a peer disconnecting
|
||||||
@@ -193,7 +192,6 @@ final class MockTransport: Transport {
|
|||||||
peerNicknames.removeValue(forKey: peerID)
|
peerNicknames.removeValue(forKey: peerID)
|
||||||
delegate?.didDisconnectFromPeer(peerID)
|
delegate?.didDisconnectFromPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
publishPeerSnapshots()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulates receiving a message
|
/// Simulates receiving a message
|
||||||
@@ -226,22 +224,5 @@ final class MockTransport: Transport {
|
|||||||
/// Updates the peer snapshot publisher
|
/// Updates the peer snapshot publisher
|
||||||
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
||||||
peerSnapshotSubject.send(snapshots)
|
peerSnapshotSubject.send(snapshots)
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publishPeerSnapshots() {
|
|
||||||
let now = Date()
|
|
||||||
let snapshots = connectedPeers.map { peerID in
|
|
||||||
TransportPeerSnapshot(
|
|
||||||
peerID: peerID,
|
|
||||||
nickname: peerNicknames[peerID] ?? "",
|
|
||||||
isConnected: true,
|
|
||||||
noisePublicKey: Data(hexString: peerID.bare),
|
|
||||||
lastSeen: now
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updatePeerSnapshots(snapshots)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -746,8 +746,8 @@ struct NoiseProtocolTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get transport ciphers
|
// Get transport ciphers
|
||||||
let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
|
let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||||
let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
|
let (respSend, respRecv) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||||
|
|
||||||
// Test transport messages (messages after the 3 handshake messages)
|
// Test transport messages (messages after the 3 handshake messages)
|
||||||
for index in 3..<testVector.messages.count {
|
for index in 3..<testVector.messages.count {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
//
|
|
||||||
// NotificationBlockingTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
// BCH-01-012: Tests for notification blocking feature
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct NotificationBlockingTests {
|
|
||||||
|
|
||||||
// MARK: - Nostr Blocking Tests
|
|
||||||
|
|
||||||
@Test("isNostrBlocked returns true for blocked pubkeys")
|
|
||||||
func isNostrBlocked_returnsTrueForBlockedPubkey() {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let manager = MockIdentityManager(keychain)
|
|
||||||
|
|
||||||
let testPubkey = "abc123def456".lowercased()
|
|
||||||
|
|
||||||
// Initially not blocked
|
|
||||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
|
||||||
|
|
||||||
// Block the pubkey
|
|
||||||
manager.setNostrBlocked(testPubkey, isBlocked: true)
|
|
||||||
|
|
||||||
// Now should be blocked
|
|
||||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == true)
|
|
||||||
|
|
||||||
// Unblock
|
|
||||||
manager.setNostrBlocked(testPubkey, isBlocked: false)
|
|
||||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("isBlocked returns true for blocked fingerprints")
|
|
||||||
func isBlocked_returnsTrueForBlockedFingerprint() {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let manager = MockIdentityManager(keychain)
|
|
||||||
|
|
||||||
let testFingerprint = "fingerprint123"
|
|
||||||
|
|
||||||
// Initially not blocked
|
|
||||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
|
||||||
|
|
||||||
// Block the fingerprint
|
|
||||||
manager.setBlocked(testFingerprint, isBlocked: true)
|
|
||||||
|
|
||||||
// Now should be blocked
|
|
||||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == true)
|
|
||||||
|
|
||||||
// Unblock
|
|
||||||
manager.setBlocked(testFingerprint, isBlocked: false)
|
|
||||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("getBlockedNostrPubkeys returns all blocked pubkeys")
|
|
||||||
func getBlockedNostrPubkeys_returnsAllBlocked() {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let manager = MockIdentityManager(keychain)
|
|
||||||
|
|
||||||
let pubkey1 = "pubkey1".lowercased()
|
|
||||||
let pubkey2 = "pubkey2".lowercased()
|
|
||||||
let pubkey3 = "pubkey3".lowercased()
|
|
||||||
|
|
||||||
manager.setNostrBlocked(pubkey1, isBlocked: true)
|
|
||||||
manager.setNostrBlocked(pubkey2, isBlocked: true)
|
|
||||||
manager.setNostrBlocked(pubkey3, isBlocked: true)
|
|
||||||
|
|
||||||
let blocked = manager.getBlockedNostrPubkeys()
|
|
||||||
|
|
||||||
#expect(blocked.count == 3)
|
|
||||||
#expect(blocked.contains(pubkey1))
|
|
||||||
#expect(blocked.contains(pubkey2))
|
|
||||||
#expect(blocked.contains(pubkey3))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Blocking Tests
|
|
||||||
|
|
||||||
@Test("BitchatMessage with blocked sender is identified")
|
|
||||||
func bitchatMessage_blockedSenderIdentified() {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let manager = MockIdentityManager(keychain)
|
|
||||||
|
|
||||||
let blockedFingerprint = "blocked_fingerprint_123"
|
|
||||||
manager.setBlocked(blockedFingerprint, isBlocked: true)
|
|
||||||
|
|
||||||
#expect(manager.isBlocked(fingerprint: blockedFingerprint) == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Case insensitive blocking for Nostr pubkeys")
|
|
||||||
func nostrBlocking_caseInsensitive() {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let manager = MockIdentityManager(keychain)
|
|
||||||
|
|
||||||
let pubkeyLower = "abc123def456"
|
|
||||||
|
|
||||||
// Block lowercase
|
|
||||||
manager.setNostrBlocked(pubkeyLower, isBlocked: true)
|
|
||||||
|
|
||||||
// Check lowercase is blocked
|
|
||||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: pubkeyLower) == true)
|
|
||||||
|
|
||||||
// Note: The API expects lowercased input, so callers must normalize
|
|
||||||
// This test verifies the contract that pubkeys should be lowercased before checking
|
|
||||||
// The fix in ChatViewModel+Nostr.swift normalizes via event.pubkey.lowercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,8 +55,6 @@ struct BinaryProtocolTests {
|
|||||||
#expect(decodedPacket.signature == TestConstants.testSignature)
|
#expect(decodedPacket.signature == TestConstants.testSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Source-Based Routing Tests (v2 only)
|
|
||||||
|
|
||||||
@Test func packetWithRouteRoundTrip() throws {
|
@Test func packetWithRouteRoundTrip() throws {
|
||||||
let route: [Data] = [
|
let route: [Data] = [
|
||||||
try #require(Data(hexString: "0102030405060708")),
|
try #require(Data(hexString: "0102030405060708")),
|
||||||
@@ -64,7 +62,6 @@ struct BinaryProtocolTests {
|
|||||||
try #require(Data(hexString: "2122232425262728"))
|
try #require(Data(hexString: "2122232425262728"))
|
||||||
]
|
]
|
||||||
|
|
||||||
// Route is only supported for v2+ packets
|
|
||||||
var packet = BitchatPacket(
|
var packet = BitchatPacket(
|
||||||
type: 0x01,
|
type: 0x01,
|
||||||
senderID: route[0],
|
senderID: route[0],
|
||||||
@@ -72,8 +69,7 @@ struct BinaryProtocolTests {
|
|||||||
timestamp: 1_720_000_000_000,
|
timestamp: 1_720_000_000_000,
|
||||||
payload: Data("route-test".utf8),
|
payload: Data("route-test".utf8),
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: 6,
|
ttl: 6
|
||||||
version: 2
|
|
||||||
)
|
)
|
||||||
packet.route = route
|
packet.route = route
|
||||||
|
|
||||||
@@ -82,7 +78,6 @@ struct BinaryProtocolTests {
|
|||||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
|
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
|
||||||
|
|
||||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
|
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
|
||||||
#expect(decoded.version == 2)
|
|
||||||
let decodedRoute = try #require(decoded.route)
|
let decodedRoute = try #require(decoded.route)
|
||||||
#expect(decodedRoute.count == route.count)
|
#expect(decodedRoute.count == route.count)
|
||||||
for (expected, actual) in zip(route, decodedRoute) {
|
for (expected, actual) in zip(route, decodedRoute) {
|
||||||
@@ -95,7 +90,6 @@ struct BinaryProtocolTests {
|
|||||||
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
|
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
|
||||||
let shortHop = Data([0xAA, 0xBB, 0xCC])
|
let shortHop = Data([0xAA, 0xBB, 0xCC])
|
||||||
|
|
||||||
// Route is only supported for v2+ packets
|
|
||||||
var packet = BitchatPacket(
|
var packet = BitchatPacket(
|
||||||
type: 0x02,
|
type: 0x02,
|
||||||
senderID: sender,
|
senderID: sender,
|
||||||
@@ -103,8 +97,7 @@ struct BinaryProtocolTests {
|
|||||||
timestamp: 1_730_000_000_000,
|
timestamp: 1_730_000_000_000,
|
||||||
payload: Data("pad-test".utf8),
|
payload: Data("pad-test".utf8),
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: 5,
|
ttl: 5
|
||||||
version: 2
|
|
||||||
)
|
)
|
||||||
packet.route = [shortHop, destination]
|
packet.route = [shortHop, destination]
|
||||||
|
|
||||||
@@ -124,7 +117,6 @@ struct BinaryProtocolTests {
|
|||||||
try #require(Data(hexString: "0202020202020202"))
|
try #require(Data(hexString: "0202020202020202"))
|
||||||
]
|
]
|
||||||
let repeatedString = String(repeating: "compress-me", count: 150)
|
let repeatedString = String(repeating: "compress-me", count: 150)
|
||||||
// Route is only supported for v2+ packets
|
|
||||||
var packet = BitchatPacket(
|
var packet = BitchatPacket(
|
||||||
type: 0x03,
|
type: 0x03,
|
||||||
senderID: route[0],
|
senderID: route[0],
|
||||||
@@ -132,8 +124,7 @@ struct BinaryProtocolTests {
|
|||||||
timestamp: 1_740_000_000_000,
|
timestamp: 1_740_000_000_000,
|
||||||
payload: Data(repeatedString.utf8),
|
payload: Data(repeatedString.utf8),
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: 7,
|
ttl: 7
|
||||||
version: 2
|
|
||||||
)
|
)
|
||||||
packet.route = route
|
packet.route = route
|
||||||
|
|
||||||
@@ -144,146 +135,6 @@ struct BinaryProtocolTests {
|
|||||||
#expect(decodedRoute == route)
|
#expect(decodedRoute == route)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func v1PacketIgnoresRouteOnEncode() throws {
|
|
||||||
// v1 packets should NOT include route even if route is set on the packet object
|
|
||||||
let route: [Data] = [
|
|
||||||
try #require(Data(hexString: "0102030405060708")),
|
|
||||||
try #require(Data(hexString: "1112131415161718"))
|
|
||||||
]
|
|
||||||
|
|
||||||
var packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: route[0],
|
|
||||||
recipientID: route.last,
|
|
||||||
timestamp: 1_720_000_000_000,
|
|
||||||
payload: Data("v1-no-route".utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 6
|
|
||||||
// version defaults to 1 (v1 packet)
|
|
||||||
)
|
|
||||||
packet.route = route // route is set but should be ignored for v1
|
|
||||||
|
|
||||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v1 packet")
|
|
||||||
|
|
||||||
// HAS_ROUTE flag should NOT be set for v1 packets
|
|
||||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
|
||||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v1 packet should not have HAS_ROUTE flag set")
|
|
||||||
|
|
||||||
// Decoded packet should have no route
|
|
||||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v1 packet")
|
|
||||||
#expect(decoded.version == 1)
|
|
||||||
#expect(decoded.route == nil, "v1 packet should decode with nil route")
|
|
||||||
#expect(decoded.payload == Data("v1-no-route".utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func v2PacketIncludesRouteOnEncode() throws {
|
|
||||||
// v2 packets SHOULD include route when route is set
|
|
||||||
let route: [Data] = [
|
|
||||||
try #require(Data(hexString: "0102030405060708")),
|
|
||||||
try #require(Data(hexString: "1112131415161718"))
|
|
||||||
]
|
|
||||||
|
|
||||||
var packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: route[0],
|
|
||||||
recipientID: route.last,
|
|
||||||
timestamp: 1_720_000_000_000,
|
|
||||||
payload: Data("v2-with-route".utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 6,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
packet.route = route
|
|
||||||
|
|
||||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet")
|
|
||||||
|
|
||||||
// HAS_ROUTE flag SHOULD be set for v2 packets with route
|
|
||||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
|
||||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, "v2 packet should have HAS_ROUTE flag set")
|
|
||||||
|
|
||||||
// Decoded packet should have route
|
|
||||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet")
|
|
||||||
#expect(decoded.version == 2)
|
|
||||||
let decodedRoute = try #require(decoded.route, "v2 packet should decode with route")
|
|
||||||
#expect(decodedRoute.count == route.count)
|
|
||||||
#expect(decoded.payload == Data("v2-with-route".utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func v2PacketWithoutRouteDecodesCorrectly() throws {
|
|
||||||
// v2 packet without route should still work
|
|
||||||
let sender = try #require(Data(hexString: "0011223344556677"))
|
|
||||||
let recipient = try #require(Data(hexString: "8899aabbccddeeff"))
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: 0x02,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: recipient,
|
|
||||||
timestamp: 1_750_000_000_000,
|
|
||||||
payload: Data("v2-no-route".utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 5,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
// route is nil by default
|
|
||||||
|
|
||||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet without route")
|
|
||||||
|
|
||||||
// HAS_ROUTE flag should NOT be set when no route
|
|
||||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
|
||||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v2 packet without route should not have HAS_ROUTE flag")
|
|
||||||
|
|
||||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet without route")
|
|
||||||
#expect(decoded.version == 2)
|
|
||||||
#expect(decoded.route == nil)
|
|
||||||
#expect(decoded.payload == Data("v2-no-route".utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func v1AndV2PayloadLengthDifference() throws {
|
|
||||||
// Verify that payloadLength does NOT include route bytes
|
|
||||||
// by comparing encoded sizes
|
|
||||||
let route: [Data] = [
|
|
||||||
try #require(Data(hexString: "0102030405060708"))
|
|
||||||
]
|
|
||||||
let payloadData = Data("test-payload".utf8)
|
|
||||||
|
|
||||||
// v1 packet (route ignored)
|
|
||||||
var v1Packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: route[0],
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: 1_720_000_000_000,
|
|
||||||
payload: payloadData,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 6
|
|
||||||
// version defaults to 1
|
|
||||||
)
|
|
||||||
v1Packet.route = route // will be ignored for v1
|
|
||||||
|
|
||||||
// v2 packet with same payload but route included
|
|
||||||
var v2Packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: route[0],
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: 1_720_000_000_000,
|
|
||||||
payload: payloadData,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 6,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
v2Packet.route = route
|
|
||||||
|
|
||||||
let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false))
|
|
||||||
let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false))
|
|
||||||
|
|
||||||
// v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop)
|
|
||||||
// Header: v1=14, v2=16 -> +2 bytes
|
|
||||||
// Route: 1 + 8 = 9 bytes
|
|
||||||
// Total expected difference: 11 bytes
|
|
||||||
let expectedDiff = 2 + 1 + 8 // header diff + route count + one hop
|
|
||||||
#expect(v2Encoded.count - v1Encoded.count == expectedDiff,
|
|
||||||
"v2 packet should be \(expectedDiff) bytes larger than v1 (actual diff: \(v2Encoded.count - v1Encoded.count))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Compression Tests
|
// MARK: - Compression Tests
|
||||||
|
|
||||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
//
|
|
||||||
// PublicMessagePipelineTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for PublicMessagePipeline ordering and deduplication.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
|
||||||
private let dedupService = MessageDeduplicationService()
|
|
||||||
var messages: [BitchatMessage] = []
|
|
||||||
|
|
||||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
|
||||||
self.messages = messages
|
|
||||||
}
|
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
|
||||||
dedupService.normalizedContentKey(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
|
||||||
dedupService.contentTimestamp(forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
|
||||||
dedupService.recordContentKey(key, timestamp: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
|
|
||||||
|
|
||||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
|
|
||||||
|
|
||||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PublicMessagePipelineTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func flush_sortsByTimestamp() async {
|
|
||||||
let pipeline = PublicMessagePipeline()
|
|
||||||
let delegate = TestPipelineDelegate()
|
|
||||||
pipeline.delegate = delegate
|
|
||||||
|
|
||||||
let earlier = Date().addingTimeInterval(-10)
|
|
||||||
let later = Date()
|
|
||||||
|
|
||||||
let messageA = BitchatMessage(
|
|
||||||
id: "a",
|
|
||||||
sender: "A",
|
|
||||||
content: "Later",
|
|
||||||
timestamp: later,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
let messageB = BitchatMessage(
|
|
||||||
id: "b",
|
|
||||||
sender: "A",
|
|
||||||
content: "Earlier",
|
|
||||||
timestamp: earlier,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.enqueue(messageA)
|
|
||||||
pipeline.enqueue(messageB)
|
|
||||||
pipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(delegate.messages.map { $0.id } == ["b", "a"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func flush_deduplicatesByContentWithinWindow() async {
|
|
||||||
let pipeline = PublicMessagePipeline()
|
|
||||||
let delegate = TestPipelineDelegate()
|
|
||||||
pipeline.delegate = delegate
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
let messageA = BitchatMessage(
|
|
||||||
id: "a",
|
|
||||||
sender: "A",
|
|
||||||
content: "Same",
|
|
||||||
timestamp: now,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
let messageB = BitchatMessage(
|
|
||||||
id: "b",
|
|
||||||
sender: "A",
|
|
||||||
content: "Same",
|
|
||||||
timestamp: now.addingTimeInterval(0.2),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.enqueue(messageA)
|
|
||||||
pipeline.enqueue(messageB)
|
|
||||||
pipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(delegate.messages.count == 1)
|
|
||||||
#expect(delegate.messages.first?.content == "Same")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func lateInsert_meshAppendsRecentOlderMessage() async {
|
|
||||||
let pipeline = PublicMessagePipeline()
|
|
||||||
let delegate = TestPipelineDelegate()
|
|
||||||
pipeline.delegate = delegate
|
|
||||||
pipeline.updateActiveChannel(.mesh)
|
|
||||||
|
|
||||||
let base = Date()
|
|
||||||
let newer = BitchatMessage(
|
|
||||||
id: "new",
|
|
||||||
sender: "A",
|
|
||||||
content: "New",
|
|
||||||
timestamp: base,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
let older = BitchatMessage(
|
|
||||||
id: "old",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: base.addingTimeInterval(-5),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
|
|
||||||
delegate.messages = [newer]
|
|
||||||
pipeline.enqueue(older)
|
|
||||||
pipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(delegate.messages.map { $0.id } == ["new", "old"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func lateInsert_locationInsertsByTimestamp() async {
|
|
||||||
let pipeline = PublicMessagePipeline()
|
|
||||||
let delegate = TestPipelineDelegate()
|
|
||||||
pipeline.delegate = delegate
|
|
||||||
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
|
|
||||||
let base = Date()
|
|
||||||
let newer = BitchatMessage(
|
|
||||||
id: "new",
|
|
||||||
sender: "A",
|
|
||||||
content: "New",
|
|
||||||
timestamp: base,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
let older = BitchatMessage(
|
|
||||||
id: "old",
|
|
||||||
sender: "A",
|
|
||||||
content: "Old",
|
|
||||||
timestamp: base.addingTimeInterval(-5),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
|
|
||||||
delegate.messages = [newer]
|
|
||||||
pipeline.enqueue(older)
|
|
||||||
pipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(delegate.messages.map { $0.id } == ["old", "new"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,13 +20,9 @@ struct MeshTopologyTrackerTests {
|
|||||||
let a = try hex("0102030405060708")
|
let a = try hex("0102030405060708")
|
||||||
let b = try hex("1112131415161718")
|
let b = try hex("1112131415161718")
|
||||||
|
|
||||||
// Bidirectional announcement
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
|
||||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
|
||||||
|
|
||||||
let route = try #require(tracker.computeRoute(from: a, to: b))
|
let route = try #require(tracker.computeRoute(from: a, to: b))
|
||||||
// Direct connection returns empty route (no intermediate hops)
|
#expect(route == [a, b])
|
||||||
#expect(route == [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func multiHopRouteComputation() throws {
|
@Test func multiHopRouteComputation() throws {
|
||||||
@@ -36,38 +32,41 @@ struct MeshTopologyTrackerTests {
|
|||||||
let c = try hex("2021222324252627")
|
let c = try hex("2021222324252627")
|
||||||
let d = try hex("3031323334353637")
|
let d = try hex("3031323334353637")
|
||||||
|
|
||||||
// Bidirectional announcements for A-B, B-C, C-D
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
tracker.recordDirectLink(between: b, and: c)
|
||||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
tracker.recordDirectLink(between: c, and: d)
|
||||||
tracker.updateNeighbors(for: c, neighbors: [b, d])
|
|
||||||
tracker.updateNeighbors(for: d, neighbors: [c])
|
|
||||||
|
|
||||||
let route = try #require(tracker.computeRoute(from: a, to: d))
|
let route = try #require(tracker.computeRoute(from: a, to: d))
|
||||||
// Route should only contain intermediate hops (b, c), excluding start (a) and end (d)
|
#expect(route == [a, b, c, d])
|
||||||
#expect(route == [b, c])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unconfirmedEdgeDoesNotRoute() throws {
|
@Test func recordRouteAddsEdges() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
var a = Data([0xAA, 0xBB, 0xCC])
|
||||||
|
let b = try hex("4445464748494A4B")
|
||||||
|
let c = try hex("5455565758595A5B")
|
||||||
|
|
||||||
|
tracker.recordRoute([a, b, c])
|
||||||
|
|
||||||
|
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
|
||||||
|
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
|
#expect(route.first == a)
|
||||||
|
#expect(route.last == c)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removingDirectLinkBreaksRoute() throws {
|
||||||
let tracker = MeshTopologyTracker()
|
let tracker = MeshTopologyTracker()
|
||||||
let a = try hex("0101010101010101")
|
let a = try hex("0101010101010101")
|
||||||
let b = try hex("0202020202020202")
|
let b = try hex("0202020202020202")
|
||||||
let c = try hex("0303030303030303")
|
let c = try hex("0303030303030303")
|
||||||
|
|
||||||
// A announces B (confirmed)
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
// B announces A, C (confirmed A-B, unconfirmed B-C)
|
tracker.recordDirectLink(between: b, and: c)
|
||||||
// C does NOT announce B
|
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
#expect(initialRoute == [a, b, c])
|
||||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
|
||||||
// C is silent or announces empty
|
|
||||||
|
|
||||||
// Should NOT find route A->C because B->C is unconfirmed (C didn't announce B)
|
tracker.removeDirectLink(between: b, and: c)
|
||||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||||
|
|
||||||
// Now C announces B
|
|
||||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
|
||||||
// Should find route
|
|
||||||
let route = try #require(tracker.computeRoute(from: a, to: c))
|
|
||||||
#expect(route == [b])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func removingPeerClearsEdges() throws {
|
@Test func removingPeerClearsEdges() throws {
|
||||||
@@ -76,28 +75,12 @@ struct MeshTopologyTrackerTests {
|
|||||||
let b = try hex("0A0B0C0D0E0F0001")
|
let b = try hex("0A0B0C0D0E0F0001")
|
||||||
let c = try hex("0011223344556677")
|
let c = try hex("0011223344556677")
|
||||||
|
|
||||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
tracker.recordRoute([a, b, c])
|
||||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
|
||||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
|
||||||
|
|
||||||
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
#expect(initialRoute == [b])
|
#expect(initialRoute == [a, b, c])
|
||||||
|
|
||||||
tracker.removePeer(b)
|
tracker.removePeer(b)
|
||||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sameStartAndEndReturnsEmptyRoute() throws {
|
|
||||||
let tracker = MeshTopologyTracker()
|
|
||||||
let a = try hex("0102030405060708")
|
|
||||||
let b = try hex("1112131415161718")
|
|
||||||
|
|
||||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
|
||||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
|
||||||
|
|
||||||
// When start == end, route should be empty (no intermediate hops needed)
|
|
||||||
let route = try #require(tracker.computeRoute(from: a, to: a))
|
|
||||||
#expect(route == [])
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageRouterTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for MessageRouter transport selection and outbox behavior.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct MessageRouterTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivate_usesReachableTransport() async {
|
|
||||||
let peerID = PeerID(str: "0000000000000001")
|
|
||||||
let transportA = MockTransport()
|
|
||||||
let transportB = MockTransport()
|
|
||||||
transportB.reachablePeers.insert(peerID)
|
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transportA, transportB])
|
|
||||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1")
|
|
||||||
|
|
||||||
#expect(transportA.sentPrivateMessages.isEmpty)
|
|
||||||
#expect(transportB.sentPrivateMessages.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivate_queuesThenFlushesWhenReachable() async {
|
|
||||||
let peerID = PeerID(str: "0000000000000002")
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transport])
|
|
||||||
router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2")
|
|
||||||
|
|
||||||
#expect(transport.sentPrivateMessages.isEmpty)
|
|
||||||
|
|
||||||
transport.reachablePeers.insert(peerID)
|
|
||||||
router.flushOutbox(for: peerID)
|
|
||||||
|
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendReadReceipt_usesReachableTransport() async {
|
|
||||||
let peerID = PeerID(str: "0000000000000003")
|
|
||||||
let transport = MockTransport()
|
|
||||||
transport.reachablePeers.insert(peerID)
|
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transport])
|
|
||||||
let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me")
|
|
||||||
router.sendReadReceipt(receipt, to: peerID)
|
|
||||||
|
|
||||||
#expect(transport.sentReadReceipts.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendFavoriteNotification_usesConnectedOrReachable() async {
|
|
||||||
let peerID = PeerID(str: "0000000000000004")
|
|
||||||
let transport = MockTransport()
|
|
||||||
transport.reachablePeers.insert(peerID)
|
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transport])
|
|
||||||
router.sendFavoriteNotification(to: peerID, isFavorite: true)
|
|
||||||
|
|
||||||
#expect(transport.sentFavoriteNotifications.count == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
//
|
|
||||||
// PrivateChatManagerTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct PrivateChatManagerTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func startChat_setsSelectedAndClearsUnread() async {
|
|
||||||
let transport = MockTransport()
|
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
|
||||||
let peerID = PeerID(str: "00000000000000AA")
|
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "pm-1",
|
|
||||||
sender: "Peer",
|
|
||||||
content: "Hi",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
]
|
|
||||||
manager.unreadMessages.insert(peerID)
|
|
||||||
|
|
||||||
manager.startChat(with: peerID)
|
|
||||||
|
|
||||||
#expect(manager.selectedPeer == peerID)
|
|
||||||
#expect(!manager.unreadMessages.contains(peerID))
|
|
||||||
#expect(manager.privateChats[peerID] != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
|
||||||
let transport = MockTransport()
|
|
||||||
let router = MessageRouter(transports: [transport])
|
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
|
||||||
manager.messageRouter = router
|
|
||||||
|
|
||||||
let peerID = PeerID(str: "00000000000000BB")
|
|
||||||
transport.reachablePeers.insert(peerID)
|
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
|
||||||
BitchatMessage(
|
|
||||||
id: "pm-2",
|
|
||||||
sender: "Peer",
|
|
||||||
content: "Hi",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
]
|
|
||||||
manager.unreadMessages.insert(peerID)
|
|
||||||
|
|
||||||
manager.markAsRead(from: peerID)
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(transport.sentReadReceipts.count == 1)
|
|
||||||
#expect(manager.sentReadReceipts.contains("pm-2"))
|
|
||||||
#expect(!manager.unreadMessages.contains(peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// RelayControllerTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for relay decision logic.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct RelayControllerTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func ttlOne_doesNotRelay() async {
|
|
||||||
let decision = RelayController.decide(
|
|
||||||
ttl: 1,
|
|
||||||
senderIsSelf: false,
|
|
||||||
isEncrypted: false,
|
|
||||||
isDirectedEncrypted: false,
|
|
||||||
isFragment: false,
|
|
||||||
isDirectedFragment: false,
|
|
||||||
isHandshake: false,
|
|
||||||
isAnnounce: false,
|
|
||||||
degree: 0,
|
|
||||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(!decision.shouldRelay)
|
|
||||||
#expect(decision.newTTL == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func handshake_alwaysRelaysWithTTLDecrement() async {
|
|
||||||
let decision = RelayController.decide(
|
|
||||||
ttl: 3,
|
|
||||||
senderIsSelf: false,
|
|
||||||
isEncrypted: false,
|
|
||||||
isDirectedEncrypted: false,
|
|
||||||
isFragment: false,
|
|
||||||
isDirectedFragment: false,
|
|
||||||
isHandshake: true,
|
|
||||||
isAnnounce: false,
|
|
||||||
degree: 3,
|
|
||||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(decision.shouldRelay)
|
|
||||||
#expect(decision.newTTL == 2)
|
|
||||||
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func fragment_relaysWithFragmentCap() async {
|
|
||||||
let decision = RelayController.decide(
|
|
||||||
ttl: 10,
|
|
||||||
senderIsSelf: false,
|
|
||||||
isEncrypted: false,
|
|
||||||
isDirectedEncrypted: false,
|
|
||||||
isFragment: true,
|
|
||||||
isDirectedFragment: false,
|
|
||||||
isHandshake: false,
|
|
||||||
isAnnounce: false,
|
|
||||||
degree: 3,
|
|
||||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
|
||||||
)
|
|
||||||
|
|
||||||
let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)
|
|
||||||
let expected = ttlCap &- 1
|
|
||||||
|
|
||||||
#expect(decision.shouldRelay)
|
|
||||||
#expect(decision.newTTL == expected)
|
|
||||||
#expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
|
|
||||||
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func denseGraph_capsTTL() async {
|
|
||||||
let decision = RelayController.decide(
|
|
||||||
ttl: 10,
|
|
||||||
senderIsSelf: false,
|
|
||||||
isEncrypted: false,
|
|
||||||
isDirectedEncrypted: false,
|
|
||||||
isFragment: false,
|
|
||||||
isDirectedFragment: false,
|
|
||||||
isHandshake: false,
|
|
||||||
isAnnounce: false,
|
|
||||||
degree: TransportConfig.bleHighDegreeThreshold,
|
|
||||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(decision.shouldRelay)
|
|
||||||
#expect(decision.newTTL == 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
//
|
|
||||||
// UnifiedPeerServiceTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for UnifiedPeerService fingerprint and block resolution.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct UnifiedPeerServiceTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func getFingerprint_prefersMeshService() async {
|
|
||||||
let transport = MockTransport()
|
|
||||||
let identity = TestIdentityManager()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
|
||||||
|
|
||||||
let peerID = PeerID(str: "00000000000000CC")
|
|
||||||
transport.peerFingerprints[peerID] = "fp-1"
|
|
||||||
|
|
||||||
let fingerprint = service.getFingerprint(for: peerID)
|
|
||||||
|
|
||||||
#expect(fingerprint == "fp-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func isBlocked_usesSocialIdentity() async {
|
|
||||||
let transport = MockTransport()
|
|
||||||
let identity = TestIdentityManager()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
|
||||||
|
|
||||||
let peerID = PeerID(str: "00000000000000DD")
|
|
||||||
let fingerprint = "fp-blocked"
|
|
||||||
transport.peerFingerprints[peerID] = fingerprint
|
|
||||||
identity.setBlocked(fingerprint, isBlocked: true)
|
|
||||||
|
|
||||||
#expect(service.isBlocked(peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
|
||||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
|
||||||
private var favorites: Set<String> = []
|
|
||||||
private var blockedNostr: Set<String> = []
|
|
||||||
private var verified: Set<String> = []
|
|
||||||
|
|
||||||
func forceSave() {}
|
|
||||||
|
|
||||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
|
||||||
socialIdentities[fingerprint]
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
|
||||||
|
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
|
||||||
socialIdentities[identity.fingerprint] = identity
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFavorites() -> Set<String> {
|
|
||||||
favorites
|
|
||||||
}
|
|
||||||
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
|
||||||
if isFavorite {
|
|
||||||
favorites.insert(fingerprint)
|
|
||||||
} else {
|
|
||||||
favorites.remove(fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFavorite(fingerprint: String) -> Bool {
|
|
||||||
favorites.contains(fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlocked(fingerprint: String) -> Bool {
|
|
||||||
socialIdentities[fingerprint]?.isBlocked ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
|
||||||
var identity = socialIdentities[fingerprint] ?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: "",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
identity.isBlocked = isBlocked
|
|
||||||
socialIdentities[fingerprint] = identity
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
||||||
blockedNostr.contains(pubkeyHexLowercased)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
|
||||||
if isBlocked {
|
|
||||||
blockedNostr.insert(pubkeyHexLowercased)
|
|
||||||
} else {
|
|
||||||
blockedNostr.remove(pubkeyHexLowercased)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBlockedNostrPubkeys() -> Set<String> {
|
|
||||||
blockedNostr
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
|
||||||
|
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}
|
|
||||||
|
|
||||||
func clearAllIdentityData() {
|
|
||||||
socialIdentities.removeAll()
|
|
||||||
favorites.removeAll()
|
|
||||||
blockedNostr.removeAll()
|
|
||||||
verified.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
|
||||||
|
|
||||||
func setVerified(fingerprint: String, verified: Bool) {
|
|
||||||
if verified {
|
|
||||||
self.verified.insert(fingerprint)
|
|
||||||
} else {
|
|
||||||
self.verified.remove(fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isVerified(fingerprint: String) -> Bool {
|
|
||||||
verified.contains(fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getVerifiedFingerprints() -> Set<String> {
|
|
||||||
verified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// SubscriptionRateLimitTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
/// Tests for BCH-01-004 fix: Rate-limiting subscription-triggered announces
|
|
||||||
/// to prevent device enumeration attacks
|
|
||||||
struct SubscriptionRateLimitTests {
|
|
||||||
|
|
||||||
@Test("Rate limit configuration values are sensible")
|
|
||||||
func rateLimitConfigurationValues() {
|
|
||||||
// Minimum interval should be at least 1 second to slow enumeration
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitMinSeconds >= 1.0)
|
|
||||||
|
|
||||||
// Backoff factor should be > 1 for exponential backoff
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitBackoffFactor > 1.0)
|
|
||||||
|
|
||||||
// Max backoff should be reasonable (not hours)
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds <= 60.0)
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds >= TransportConfig.bleSubscriptionRateLimitMinSeconds)
|
|
||||||
|
|
||||||
// Window should be long enough to track repeated attempts
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitWindowSeconds >= 30.0)
|
|
||||||
|
|
||||||
// Max attempts before suppression should be > 1 to allow legitimate reconnects
|
|
||||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxAttempts >= 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Exponential backoff calculation is correct")
|
|
||||||
func exponentialBackoffCalculation() {
|
|
||||||
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
|
|
||||||
let factor = TransportConfig.bleSubscriptionRateLimitBackoffFactor
|
|
||||||
let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
|
||||||
|
|
||||||
// Simulate backoff progression
|
|
||||||
var currentBackoff = minInterval
|
|
||||||
var iterations = 0
|
|
||||||
let maxIterations = 10
|
|
||||||
|
|
||||||
while currentBackoff < maxBackoff && iterations < maxIterations {
|
|
||||||
let nextBackoff = min(currentBackoff * factor, maxBackoff)
|
|
||||||
#expect(nextBackoff >= currentBackoff, "Backoff should increase or stay at max")
|
|
||||||
currentBackoff = nextBackoff
|
|
||||||
iterations += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should reach max within reasonable iterations
|
|
||||||
#expect(iterations <= maxIterations, "Backoff should reach max within \(maxIterations) iterations")
|
|
||||||
#expect(currentBackoff == maxBackoff, "Final backoff should equal max")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Rate limiting would significantly slow enumeration attacks")
|
|
||||||
func rateLimitingSlowsEnumeration() {
|
|
||||||
// Without rate limiting: ~120 devices/minute (0.5 seconds per device)
|
|
||||||
// With rate limiting: minimum interval enforced
|
|
||||||
|
|
||||||
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
|
|
||||||
let devicesPerMinuteWithRateLimit = 60.0 / minInterval
|
|
||||||
|
|
||||||
// Should be significantly slower than 120 devices/minute
|
|
||||||
#expect(devicesPerMinuteWithRateLimit < 60, "Rate limiting should significantly slow enumeration")
|
|
||||||
|
|
||||||
// With 2-second minimum interval, max ~30 devices/minute per connection
|
|
||||||
// And with backoff, repeated attempts are even slower
|
|
||||||
#expect(devicesPerMinuteWithRateLimit <= 30, "With 2s minimum, should be <=30/min")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Max attempts threshold prevents complete enumeration")
|
|
||||||
func maxAttemptsThresholdPreventsEnumeration() {
|
|
||||||
let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
|
||||||
|
|
||||||
// After max attempts within window, announces are suppressed entirely
|
|
||||||
// This means an attacker gets at most maxAttempts announces per window
|
|
||||||
#expect(maxAttempts >= 2, "Should allow at least 2 attempts for legitimate reconnects")
|
|
||||||
#expect(maxAttempts <= 10, "Should cap attempts to prevent enumeration")
|
|
||||||
|
|
||||||
// With 5 attempts max and 2s minimum interval, attacker gets limited info
|
|
||||||
let maxAnnounces = maxAttempts
|
|
||||||
#expect(maxAnnounces <= 10, "Max announces per window should be limited")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,22 +93,6 @@ final class TestHelpers {
|
|||||||
try await sleep(0.01)
|
try await sleep(0.01)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func waitUntil(
|
|
||||||
_ condition: @escaping () -> Bool,
|
|
||||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
|
||||||
pollInterval: TimeInterval = 0.01
|
|
||||||
) async -> Bool {
|
|
||||||
let start = Date()
|
|
||||||
while !condition() {
|
|
||||||
if Date().timeIntervalSince(start) > timeout {
|
|
||||||
return condition()
|
|
||||||
}
|
|
||||||
try? await sleep(pollInterval)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
static func expectAsync<T>(
|
static func expectAsync<T>(
|
||||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||||
|
|||||||
@@ -426,70 +426,4 @@ struct PeerIDTests {
|
|||||||
#expect(!PeerID(str: "nostr:\(hex65)").isValid)
|
#expect(!PeerID(str: "nostr:\(hex65)").isValid)
|
||||||
#expect(!PeerID(str: "nostr_\(hex65)").isValid)
|
#expect(!PeerID(str: "nostr_\(hex65)").isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File Transfer PeerID Normalization
|
|
||||||
// These tests verify the fix for asymmetric voice/media delivery (BCH-01-XXX)
|
|
||||||
// The bug occurred when selectedPrivateChatPeer was migrated to 64-hex stable key
|
|
||||||
// but the receiver expected SHA256-derived 16-hex format
|
|
||||||
|
|
||||||
@Test func fileTransfer_toShortNormalizesNoiseKeyToFingerprint() {
|
|
||||||
// Given: A 64-hex Noise public key (what selectedPrivateChatPeer becomes after session)
|
|
||||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
|
||||||
let stableKeyPeerID = PeerID(hexData: noiseKey) // 64-hex
|
|
||||||
|
|
||||||
// When: Convert to short form (what sendFilePrivate should do)
|
|
||||||
let shortID = stableKeyPeerID.toShort()
|
|
||||||
|
|
||||||
// Then: Should be 16-hex SHA256 fingerprint (matching myPeerID format)
|
|
||||||
let expected = noiseKey.sha256Fingerprint().prefix(16)
|
|
||||||
#expect(shortID.id == String(expected))
|
|
||||||
#expect(shortID.id.count == 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileTransfer_shortIDMatchesMyPeerIDFormat() {
|
|
||||||
// Given: A receiver's myPeerID is SHA256-derived (from refreshPeerIdentity)
|
|
||||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
|
||||||
let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex
|
|
||||||
|
|
||||||
// When: Sender uses toShort() on 64-hex stable key
|
|
||||||
let senderStableKey = PeerID(hexData: noiseKey) // 64-hex
|
|
||||||
let recipientData = Data(hexString: senderStableKey.toShort().id)!
|
|
||||||
let receivedRecipientID = PeerID(hexData: recipientData)
|
|
||||||
|
|
||||||
// Then: Should match receiver's myPeerID (file transfer accepted)
|
|
||||||
#expect(receivedRecipientID == myPeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileTransfer_truncatedRawKeyDoesNotMatchMyPeerID() {
|
|
||||||
// This test demonstrates the bug we fixed
|
|
||||||
// When 64-hex was truncated to first 8 bytes instead of using SHA256 fingerprint
|
|
||||||
|
|
||||||
// Given: Receiver's myPeerID is SHA256-derived
|
|
||||||
let noiseKey = Data(repeating: 0xEF, count: 32)
|
|
||||||
let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex
|
|
||||||
|
|
||||||
// When: Truncate raw key (the OLD buggy behavior)
|
|
||||||
let truncatedRaw = noiseKey.prefix(8) // First 8 bytes of raw key
|
|
||||||
let wrongRecipientID = PeerID(hexData: truncatedRaw)
|
|
||||||
|
|
||||||
// Then: Should NOT match (demonstrates why fix was needed)
|
|
||||||
#expect(wrongRecipientID != myPeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func fileTransfer_shortIDProducesCorrect8ByteRoutingData() {
|
|
||||||
// Verify the wire format is correct (8 bytes for BinaryProtocol)
|
|
||||||
let noiseKey = Data(repeating: 0x12, count: 32)
|
|
||||||
let stableKeyPeerID = PeerID(hexData: noiseKey)
|
|
||||||
let shortID = stableKeyPeerID.toShort()
|
|
||||||
|
|
||||||
// routingData should be 8 bytes (16 hex chars -> 8 bytes)
|
|
||||||
let routingData = shortID.routingData
|
|
||||||
#expect(routingData != nil)
|
|
||||||
#expect(routingData?.count == 8)
|
|
||||||
|
|
||||||
// And it should match SHA256 fingerprint first 8 bytes
|
|
||||||
let expectedFingerprint = noiseKey.sha256Fingerprint()
|
|
||||||
let expectedFirst8 = Data(hexString: String(expectedFingerprint.prefix(16)))
|
|
||||||
#expect(routingData == expectedFirst8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# Geohash Presence Specification
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts.
|
|
||||||
|
|
||||||
## Nostr Protocol
|
|
||||||
|
|
||||||
### Event Kind
|
|
||||||
A new ephemeral event kind is defined for presence heartbeats:
|
|
||||||
- **Kind:** `20001` (`GEOHASH_PRESENCE`)
|
|
||||||
- **Type:** Ephemeral (not stored by relays long-term)
|
|
||||||
|
|
||||||
### Event Structure
|
|
||||||
The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness".
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": 20001,
|
|
||||||
"created_at": <timestamp>,
|
|
||||||
"tags": [
|
|
||||||
["g", "<geohash>"]
|
|
||||||
],
|
|
||||||
"content": "",
|
|
||||||
"pubkey": "<geohash_derived_pubkey>",
|
|
||||||
"id": "<event_id>",
|
|
||||||
"sig": "<signature>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* **`content`**: Must be empty string.
|
|
||||||
* **`tags`**: Must include `["g", "<geohash>"]`. Should NOT include `["n", "<nickname>"]`.
|
|
||||||
* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages).
|
|
||||||
|
|
||||||
## Client Behavior
|
|
||||||
|
|
||||||
### 1. Broadcasting Presence
|
|
||||||
|
|
||||||
Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing.
|
|
||||||
|
|
||||||
* **Global Heartbeat:**
|
|
||||||
* **Trigger:** Application start / initialization, or whenever location (available geohashes) changes.
|
|
||||||
* **Frequency:** Randomized loop interval between **40s and 80s** (average 60s).
|
|
||||||
* **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*.
|
|
||||||
* **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically:
|
|
||||||
* **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5).
|
|
||||||
* **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+).
|
|
||||||
* **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence.
|
|
||||||
|
|
||||||
### 2. Subscribing to Presence
|
|
||||||
|
|
||||||
Clients must update their Nostr filters to listen for both chat and presence events on geohash channels.
|
|
||||||
|
|
||||||
* **Filter:**
|
|
||||||
* `kinds`: `[20000, 20001]`
|
|
||||||
* `#g`: `["<geohash>"]`
|
|
||||||
|
|
||||||
### 3. Participant Counting
|
|
||||||
|
|
||||||
The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages.
|
|
||||||
|
|
||||||
* **Logic:**
|
|
||||||
* Maintain a map of `pubkey -> last_seen_timestamp` for each geohash.
|
|
||||||
* Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event.
|
|
||||||
* A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**.
|
|
||||||
|
|
||||||
### 4. UI Presentation
|
|
||||||
|
|
||||||
The presentation of the participant count depends on the geohash precision level and data availability.
|
|
||||||
|
|
||||||
* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`.
|
|
||||||
* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where:
|
|
||||||
* Presence broadcasting is disabled (privacy restriction).
|
|
||||||
* **AND** the detected participant count is `0`.
|
|
||||||
* **Display:** `[? people]`
|
|
||||||
* **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking).
|
|
||||||
|
|
||||||
### 5. Implementation Details (Android Reference)
|
|
||||||
|
|
||||||
* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`.
|
|
||||||
* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event.
|
|
||||||
* **`GeohashViewModel`**:
|
|
||||||
* `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`.
|
|
||||||
* Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s).
|
|
||||||
* Filters channels by `precision <= 5` before broadcasting.
|
|
||||||
* **`GeohashMessageHandler`**:
|
|
||||||
* Refactored `onEvent` to update participant counts for both Kind 20000 and 20001.
|
|
||||||
* **`LocationChannelsSheet`**:
|
|
||||||
* Implements the `[? people]` display logic for high-precision, zero-count channels.
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages).
|
|
||||||
* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays.
|
|
||||||
* **Consistency:** "Online" status is maintained globally while the app is open.
|
|
||||||
* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Request Sync Manager & V2 Packet Updates
|
|
||||||
|
|
||||||
This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic.
|
|
||||||
|
|
||||||
The new implementation introduces a **RequestSyncManager** to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to:
|
|
||||||
1. **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock.
|
|
||||||
2. **Exempt Solicited Sync Responses**: Packets marked as RSR are exempt from timestamp validation *only if* they correspond to a valid, pending sync request sent to that specific peer.
|
|
||||||
3. **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected.
|
|
||||||
|
|
||||||
## Protocol Changes
|
|
||||||
|
|
||||||
### Binary Protocol Updates
|
|
||||||
* **New Flag**: `IS_RSR` (0x10) added to the packet header flags.
|
|
||||||
* **BitchatPacket**: Updated to include `isRSR: Bool` field.
|
|
||||||
* **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag.
|
|
||||||
|
|
||||||
### Request Sync Payload
|
|
||||||
The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:
|
|
||||||
* **Future Filters**:
|
|
||||||
* `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).
|
|
||||||
* `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### RequestSyncManager
|
|
||||||
A new component (`Sync/RequestSyncManager.swift`) responsible for:
|
|
||||||
* **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests.
|
|
||||||
* **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window.
|
|
||||||
* **Cleanup**: Periodically removes expired requests.
|
|
||||||
|
|
||||||
### GossipSyncManager Updates
|
|
||||||
* **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets.
|
|
||||||
* **Registration**: Before sending, requests are registered with `RequestSyncManager`.
|
|
||||||
* **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`).
|
|
||||||
|
|
||||||
### BLEService (Security Manager) Updates
|
|
||||||
* **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets.
|
|
||||||
* **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`.
|
|
||||||
* **Valid**: If solicited, timestamp check is skipped (allowing historical data sync).
|
|
||||||
* **Invalid**: If unsolicited or timed out, the packet is rejected.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
These changes are integrated into `BLEService` and `GossipSyncManager`. No external API changes are required for clients, but all peers must be updated to support the new `IS_RSR` flag and protocol logic to participate in the secure sync process.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Source-Based Routing for BitChat Packets (v2)
|
|
||||||
|
|
||||||
This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.
|
|
||||||
|
|
||||||
**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Protocol Versioning & Layering
|
|
||||||
|
|
||||||
To support source routing and larger payloads, the packet format has been upgraded to **Version 2**.
|
|
||||||
|
|
||||||
* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags.
|
|
||||||
* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing.
|
|
||||||
|
|
||||||
**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Packet Structure Comparison
|
|
||||||
|
|
||||||
The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.
|
|
||||||
|
|
||||||
### V1 Packet (Legacy)
|
|
||||||
```text
|
|
||||||
+-------------------+---------------------------------------------------------+
|
|
||||||
| Fixed Header (14) | Variable Sections |
|
|
||||||
+-------------------+----------+-------------+------------------+-------------+
|
|
||||||
| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature |
|
|
||||||
| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) |
|
|
||||||
| Len: 2 Bytes | | (Optional) | | (Optional) |
|
|
||||||
+-------------------+----------+-------------+------------------+-------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
### V2 Packet (Source Routed)
|
|
||||||
```text
|
|
||||||
+-------------------+-----------------------------------------------------------------------------+
|
|
||||||
| Fixed Header (16) | Variable Sections |
|
|
||||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
|
||||||
| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature |
|
|
||||||
| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) |
|
|
||||||
| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) |
|
|
||||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set).
|
|
||||||
|
|
||||||
### Fixed Header Differences
|
|
||||||
|
|
||||||
| Field | Size (v1) | Size (v2) | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` |
|
|
||||||
| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. |
|
|
||||||
| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Source Route Specification
|
|
||||||
|
|
||||||
The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse.
|
|
||||||
|
|
||||||
* **Location:** Immediately follows `RecipientID`.
|
|
||||||
* **Structure:**
|
|
||||||
* `Count` (1 byte): Number of intermediate hops (`N`).
|
|
||||||
* `Hops` (`N * 8` bytes): Sequence of Peer IDs.
|
|
||||||
|
|
||||||
### Intermediate Hops Only
|
|
||||||
The route list MUST contain **only** the intermediate relays between the sender and the recipient.
|
|
||||||
* **DO NOT** include the `SenderID` (it is already in the packet).
|
|
||||||
* **DO NOT** include the `RecipientID` (it is already in the packet).
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)`
|
|
||||||
* Packet `SenderID`: Alice
|
|
||||||
* Packet `RecipientID`: Dave
|
|
||||||
* Packet `Route`: `[Bob, Charlie]` (Count = 2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Topology Discovery (Gossip)
|
|
||||||
|
|
||||||
To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet.
|
|
||||||
|
|
||||||
The `ANNOUNCE` packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV.
|
|
||||||
|
|
||||||
* **Mechanism:** Appended to the `IdentityAnnouncement` payload.
|
|
||||||
* **New TLV Type:** `0x04` (Direct Neighbors).
|
|
||||||
* **Content:** A list of Peer IDs that the announcing node is directly connected to.
|
|
||||||
|
|
||||||
**TLV Structure (Type 0x04):**
|
|
||||||
```text
|
|
||||||
[Type: 0x04] [Length: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...
|
|
||||||
```
|
|
||||||
The `Length` field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field.
|
|
||||||
|
|
||||||
Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.
|
|
||||||
|
|
||||||
### Edge Verification (Two-Way Handshake)
|
|
||||||
|
|
||||||
To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification:
|
|
||||||
|
|
||||||
* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations.
|
|
||||||
* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fragmentation & Source Routing
|
|
||||||
|
|
||||||
When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:
|
|
||||||
|
|
||||||
1. **Version Inheritance:** All fragments MUST be marked as **Version 2**.
|
|
||||||
2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet.
|
|
||||||
|
|
||||||
**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Security & Signing
|
|
||||||
|
|
||||||
Source routing is fully secured by the existing Ed25519 signature scheme.
|
|
||||||
|
|
||||||
* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload).
|
|
||||||
* **Verification:** The receiver verifies the signature against the `SenderID`'s public key.
|
|
||||||
* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.
|
|
||||||
|
|
||||||
**Signature Input Construction:**
|
|
||||||
Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Relay Logic
|
|
||||||
|
|
||||||
When a node receives a packet **not** addressed to itself:
|
|
||||||
|
|
||||||
1. **Check Route:**
|
|
||||||
* Is `Version >= 2`?
|
|
||||||
* Is `HAS_ROUTE` flag set?
|
|
||||||
* Is the route list non-empty?
|
|
||||||
2. **If YES (Source Routed):**
|
|
||||||
* Find local Peer ID in the route list at index `i`.
|
|
||||||
* **Next Hop:** The peer at `i + 1`.
|
|
||||||
* **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`.
|
|
||||||
* **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop.
|
|
||||||
* **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.
|
|
||||||
3. **If NO (Standard):**
|
|
||||||
* Flood the packet to all connected neighbors (subject to TTL and probability rules).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/target/
|
|
||||||
/.build/
|
|
||||||
/.swiftpm/
|
|
||||||
Generated
-5046
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = ["arti-bitchat"]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = "fat"
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
strip = "symbols"
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
|
||||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if already running
|
|
||||||
* * -2 if data_dir is invalid
|
|
||||||
* * -3 if runtime initialization failed
|
|
||||||
* * -4 if bootstrap failed
|
|
||||||
*/
|
|
||||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 1 if running
|
|
||||||
* * 0 if not running
|
|
||||||
*/
|
|
||||||
int arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `buf` - Buffer to write the summary into
|
|
||||||
* * `len` - Length of the buffer
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * Number of bytes written (not including null terminator)
|
|
||||||
* * -1 if buffer is null or too small
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_summary(char *buf, int len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
* This is a hint; Arti may not fully support dormant mode yet.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_wake(void);
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
BIN
Binary file not shown.
@@ -1,78 +0,0 @@
|
|||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
|
||||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if already running
|
|
||||||
* * -2 if data_dir is invalid
|
|
||||||
* * -3 if runtime initialization failed
|
|
||||||
* * -4 if bootstrap failed
|
|
||||||
*/
|
|
||||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 1 if running
|
|
||||||
* * 0 if not running
|
|
||||||
*/
|
|
||||||
int arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `buf` - Buffer to write the summary into
|
|
||||||
* * `len` - Length of the buffer
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * Number of bytes written (not including null terminator)
|
|
||||||
* * -1 if buffer is null or too small
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_summary(char *buf, int len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
* This is a hint; Arti may not fully support dormant mode yet.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_wake(void);
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
Binary file not shown.
@@ -1,78 +0,0 @@
|
|||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
|
||||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if already running
|
|
||||||
* * -2 if data_dir is invalid
|
|
||||||
* * -3 if runtime initialization failed
|
|
||||||
* * -4 if bootstrap failed
|
|
||||||
*/
|
|
||||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 1 if running
|
|
||||||
* * 0 if not running
|
|
||||||
*/
|
|
||||||
int arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `buf` - Buffer to write the summary into
|
|
||||||
* * `len` - Length of the buffer
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * Number of bytes written (not including null terminator)
|
|
||||||
* * -1 if buffer is null or too small
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_summary(char *buf, int len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
* This is a hint; Arti may not fully support dormant mode yet.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_wake(void);
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
Binary file not shown.
@@ -1,78 +0,0 @@
|
|||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
|
||||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if already running
|
|
||||||
* * -2 if data_dir is invalid
|
|
||||||
* * -3 if runtime initialization failed
|
|
||||||
* * -4 if bootstrap failed
|
|
||||||
*/
|
|
||||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 1 if running
|
|
||||||
* * 0 if not running
|
|
||||||
*/
|
|
||||||
int arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* # Arguments
|
|
||||||
* * `buf` - Buffer to write the summary into
|
|
||||||
* * `len` - Length of the buffer
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * Number of bytes written (not including null terminator)
|
|
||||||
* * -1 if buffer is null or too small
|
|
||||||
*/
|
|
||||||
int arti_bootstrap_summary(char *buf, int len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
* This is a hint; Arti may not fully support dormant mode yet.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* # Returns
|
|
||||||
* * 0 on success
|
|
||||||
* * -1 if not running
|
|
||||||
*/
|
|
||||||
int arti_wake(void);
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// swift-tools-version:5.9
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "Tor", // Keep name "Tor" for drop-in compatibility
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v16),
|
|
||||||
.macOS(.v13),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
.library(
|
|
||||||
name: "Tor",
|
|
||||||
targets: ["Tor"]
|
|
||||||
),
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(path: "../BitLogger"),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
// Main Swift target
|
|
||||||
.target(
|
|
||||||
name: "Tor",
|
|
||||||
dependencies: [
|
|
||||||
"arti",
|
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
|
||||||
],
|
|
||||||
path: "Sources",
|
|
||||||
exclude: ["C"],
|
|
||||||
sources: [
|
|
||||||
"TorManager.swift",
|
|
||||||
"TorURLSession.swift",
|
|
||||||
"TorNotifications.swift",
|
|
||||||
],
|
|
||||||
linkerSettings: [
|
|
||||||
.linkedLibrary("resolv"),
|
|
||||||
.linkedLibrary("z"),
|
|
||||||
.linkedLibrary("sqlite3"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
// Binary framework containing the Rust static library
|
|
||||||
.binaryTarget(
|
|
||||||
name: "arti",
|
|
||||||
path: "Frameworks/arti.xcframework"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Empty shim file to satisfy SPM target requirements.
|
|
||||||
// The actual implementation is in the Rust static library (arti.xcframework).
|
|
||||||
// This file exists only to make SPM happy with a C target.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* @param data_dir Path to data directory for Tor state (C string)
|
|
||||||
* @param socks_port Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
* @return 0 on success, negative on error:
|
|
||||||
* -1: already running
|
|
||||||
* -2: invalid data_dir
|
|
||||||
* -3: runtime initialization failed
|
|
||||||
* -4: bootstrap failed
|
|
||||||
*/
|
|
||||||
int32_t arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* @return 1 if running, 0 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*
|
|
||||||
* @return Progress percentage
|
|
||||||
*/
|
|
||||||
int32_t arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* @param buf Buffer to write the summary into
|
|
||||||
* @param len Length of the buffer
|
|
||||||
* @return Number of bytes written, -1 on error
|
|
||||||
*/
|
|
||||||
int32_t arti_bootstrap_summary(char *buf, int32_t len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_wake(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module ArtiC {
|
|
||||||
header "arti.h"
|
|
||||||
export *
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
#if canImport(Network)
|
|
||||||
import Network
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !canImport(Network)
|
|
||||||
private final class NWPathMonitor {
|
|
||||||
var pathUpdateHandler: ((Any) -> Void)?
|
|
||||||
|
|
||||||
func start(queue: DispatchQueue) {
|
|
||||||
// Path monitoring is unavailable on this platform; nothing to do.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// FFI declarations for Arti (Rust)
|
|
||||||
@_silgen_name("arti_start")
|
|
||||||
private func arti_start(_ dataDir: UnsafePointer<CChar>, _ socksPort: UInt16) -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_stop")
|
|
||||||
private func arti_stop() -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_is_running")
|
|
||||||
private func arti_is_running() -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_bootstrap_progress")
|
|
||||||
private func arti_bootstrap_progress() -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_bootstrap_summary")
|
|
||||||
private func arti_bootstrap_summary(_ buf: UnsafeMutablePointer<CChar>, _ len: Int32) -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_go_dormant")
|
|
||||||
private func arti_go_dormant() -> Int32
|
|
||||||
|
|
||||||
@_silgen_name("arti_wake")
|
|
||||||
private func arti_wake() -> Int32
|
|
||||||
|
|
||||||
/// Arti-based Tor integration for BitChat.
|
|
||||||
/// - Boots a local Arti client and exposes a SOCKS5 proxy
|
|
||||||
/// on 127.0.0.1:socksPort. All app networking should await readiness and
|
|
||||||
/// route via this proxy. Fails closed by default when Tor is unavailable.
|
|
||||||
@MainActor
|
|
||||||
public final class TorManager: ObservableObject {
|
|
||||||
public static let shared = TorManager()
|
|
||||||
|
|
||||||
// SOCKS endpoint where Arti listens
|
|
||||||
let socksHost: String = "127.0.0.1"
|
|
||||||
let socksPort: Int = 39050
|
|
||||||
|
|
||||||
// State
|
|
||||||
@Published private(set) public var isReady: Bool = false
|
|
||||||
@Published private(set) var isStarting: Bool = false
|
|
||||||
@Published private(set) var lastError: Error?
|
|
||||||
@Published private(set) var bootstrapProgress: Int = 0
|
|
||||||
@Published private(set) var bootstrapSummary: String = ""
|
|
||||||
|
|
||||||
// Internal readiness trackers
|
|
||||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
|
||||||
private var restarting: Bool = false
|
|
||||||
|
|
||||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
|
||||||
public var torEnforced: Bool {
|
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
|
||||||
return false
|
|
||||||
#else
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true only when Tor is actually up (or dev fallback is compiled).
|
|
||||||
var networkPermitted: Bool {
|
|
||||||
if torEnforced { return isReady }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private var didStart = false
|
|
||||||
private var bootstrapMonitorStarted = false
|
|
||||||
private var pathMonitor: NWPathMonitor?
|
|
||||||
private var isAppForeground: Bool = true
|
|
||||||
private var isDormant: Bool = false
|
|
||||||
private var lastRestartAt: Date? = nil
|
|
||||||
private var startedAt: Date? = nil // Tracks initial startup time for grace period
|
|
||||||
private(set) var allowAutoStart: Bool = false
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
public func startIfNeeded() {
|
|
||||||
guard allowAutoStart else { return }
|
|
||||||
guard isAppForeground else { return }
|
|
||||||
guard !didStart else { return }
|
|
||||||
didStart = true
|
|
||||||
isDormant = false
|
|
||||||
isStarting = true
|
|
||||||
startedAt = Date() // Track startup time for grace period
|
|
||||||
SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session)
|
|
||||||
lastError = nil
|
|
||||||
NotificationCenter.default.post(name: .TorWillStart, object: nil)
|
|
||||||
ensureFilesystemLayout()
|
|
||||||
startArti()
|
|
||||||
startPathMonitorIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setAppForeground(_ foreground: Bool) {
|
|
||||||
isAppForeground = foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isForeground() -> Bool { isAppForeground }
|
|
||||||
|
|
||||||
nonisolated
|
|
||||||
public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool {
|
|
||||||
await MainActor.run {
|
|
||||||
if self.isAppForeground { self.startIfNeeded() }
|
|
||||||
}
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
if await MainActor.run(body: { self.networkPermitted }) { return true }
|
|
||||||
while Date() < deadline {
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
if await MainActor.run(body: { self.networkPermitted }) { return true }
|
|
||||||
}
|
|
||||||
return await MainActor.run(body: { self.networkPermitted })
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Filesystem
|
|
||||||
|
|
||||||
func dataDirectoryURL() -> URL? {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat/arti", isDirectory: true)
|
|
||||||
return dir
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureFilesystemLayout() {
|
|
||||||
guard let dir = dataDirectoryURL() else { return }
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
|
||||||
// Non-fatal; Arti will surface errors during start if paths are missing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Arti Integration
|
|
||||||
|
|
||||||
private func startArti() {
|
|
||||||
guard let dir = dataDirectoryURL()?.path else {
|
|
||||||
isStarting = false
|
|
||||||
lastError = NSError(domain: "TorManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data directory"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already running
|
|
||||||
if arti_is_running() != 0 {
|
|
||||||
SecureLogger.info("TorManager: Arti already running", category: .session)
|
|
||||||
startBootstrapMonitor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = dir.withCString { dptr in
|
|
||||||
arti_start(dptr, UInt16(socksPort))
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != 0 {
|
|
||||||
SecureLogger.error("TorManager: arti_start failed rc=\(result)", category: .session)
|
|
||||||
isStarting = false
|
|
||||||
lastError = NSError(domain: "TorManager", code: Int(result), userInfo: [NSLocalizedDescriptionKey: "Arti start failed"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info("TorManager: arti_start OK (SOCKS \(socksHost):\(socksPort))", category: .session)
|
|
||||||
startBootstrapMonitor()
|
|
||||||
|
|
||||||
// Start SOCKS readiness probe
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
|
||||||
await MainActor.run {
|
|
||||||
self.socksReady = ready
|
|
||||||
if ready {
|
|
||||||
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
|
||||||
} else {
|
|
||||||
self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "SOCKS not reachable"])
|
|
||||||
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForSocksReady(timeout: TimeInterval) async -> Bool {
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
while Date() < deadline {
|
|
||||||
if await probeSocksOnce() { return true }
|
|
||||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func probeSocksOnce() async -> Bool {
|
|
||||||
#if canImport(Network)
|
|
||||||
await withCheckedContinuation { cont in
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
let host = NWEndpoint.Host.ipv4(.loopback)
|
|
||||||
guard let port = NWEndpoint.Port(rawValue: UInt16(socksPort)) else {
|
|
||||||
cont.resume(returning: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let endpoint = NWEndpoint.hostPort(host: host, port: port)
|
|
||||||
let conn = NWConnection(to: endpoint, using: params)
|
|
||||||
|
|
||||||
var resumed = false
|
|
||||||
let resumeOnce: (Bool) -> Void = { value in
|
|
||||||
if !resumed {
|
|
||||||
resumed = true
|
|
||||||
cont.resume(returning: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.stateUpdateHandler = { state in
|
|
||||||
switch state {
|
|
||||||
case .ready:
|
|
||||||
resumeOnce(true)
|
|
||||||
conn.cancel()
|
|
||||||
case .failed, .cancelled:
|
|
||||||
resumeOnce(false)
|
|
||||||
conn.cancel()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
resumeOnce(false)
|
|
||||||
conn.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.start(queue: DispatchQueue.global(qos: .utility))
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bootstrap Monitoring
|
|
||||||
|
|
||||||
private func startBootstrapMonitor() {
|
|
||||||
guard !bootstrapMonitorStarted else { return }
|
|
||||||
bootstrapMonitorStarted = true
|
|
||||||
Task.detached(priority: .utility) { [weak self] in
|
|
||||||
await self?.bootstrapPollLoop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bootstrapPollLoop() async {
|
|
||||||
let deadline = Date().addingTimeInterval(75)
|
|
||||||
while Date() < deadline {
|
|
||||||
let progress = Int(arti_bootstrap_progress())
|
|
||||||
let summary = getBootstrapSummary()
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
self.bootstrapProgress = progress
|
|
||||||
self.bootstrapSummary = summary
|
|
||||||
if progress >= 100 { self.isStarting = false }
|
|
||||||
self.recomputeReady()
|
|
||||||
}
|
|
||||||
|
|
||||||
if progress >= 100 { break }
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getBootstrapSummary() -> String {
|
|
||||||
var buf = [CChar](repeating: 0, count: 256)
|
|
||||||
let len = arti_bootstrap_summary(&buf, Int32(buf.count))
|
|
||||||
if len > 0 {
|
|
||||||
return String(cString: buf)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Foreground/Background
|
|
||||||
|
|
||||||
public func ensureRunningOnForeground() {
|
|
||||||
if !allowAutoStart { return }
|
|
||||||
SecureLogger.debug("TorManager: ensureRunningOnForeground() started", category: .session)
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let claimed: Bool = await MainActor.run {
|
|
||||||
if self.isStarting || self.restarting { return false }
|
|
||||||
self.restarting = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if !claimed { return }
|
|
||||||
|
|
||||||
// Check if already ready
|
|
||||||
let alreadyReady = await MainActor.run { self.isReady }
|
|
||||||
if alreadyReady {
|
|
||||||
await MainActor.run { self.restarting = false }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arti doesn't support dormant/wake (it's a no-op stub), so always do full restart
|
|
||||||
await self.restartArti()
|
|
||||||
await MainActor.run { self.restarting = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func goDormantOnBackground() {
|
|
||||||
// Arti doesn't support real dormant mode, so just mark as not ready.
|
|
||||||
// iOS will suspend the runtime anyway. On foreground we do a full restart.
|
|
||||||
// Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.
|
|
||||||
SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session)
|
|
||||||
Task { @MainActor in
|
|
||||||
self.isReady = false
|
|
||||||
self.socksReady = false
|
|
||||||
self.isStarting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func shutdownCompletely() {
|
|
||||||
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
_ = arti_stop()
|
|
||||||
|
|
||||||
// Wait for shutdown
|
|
||||||
var waited = 0
|
|
||||||
while arti_is_running() != 0 && waited < 50 {
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
waited += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
self.isDormant = false
|
|
||||||
self.isReady = false
|
|
||||||
self.socksReady = false
|
|
||||||
self.bootstrapProgress = 0
|
|
||||||
self.bootstrapSummary = ""
|
|
||||||
self.isStarting = false
|
|
||||||
self.didStart = false
|
|
||||||
self.restarting = false
|
|
||||||
self.bootstrapMonitorStarted = false
|
|
||||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
|
||||||
// Clearing it here races with startup and defeats the grace period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func restartArti() async {
|
|
||||||
SecureLogger.debug("TorManager: restartArti() starting", category: .session)
|
|
||||||
await MainActor.run {
|
|
||||||
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
|
|
||||||
self.isReady = false
|
|
||||||
self.socksReady = false
|
|
||||||
self.bootstrapProgress = 0
|
|
||||||
self.bootstrapSummary = ""
|
|
||||||
self.isStarting = true
|
|
||||||
self.isDormant = false
|
|
||||||
self.lastRestartAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = arti_stop()
|
|
||||||
|
|
||||||
// Wait for stop
|
|
||||||
var waited = 0
|
|
||||||
while arti_is_running() != 0 && waited < 40 {
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
waited += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
self.bootstrapMonitorStarted = false
|
|
||||||
self.didStart = false
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run { self.startIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recomputeReady() {
|
|
||||||
let ready = socksReady && bootstrapProgress >= 100
|
|
||||||
if ready != isReady {
|
|
||||||
if !ready {
|
|
||||||
SecureLogger.debug("TorManager: isReady -> false (socksReady=\(socksReady), bootstrap=\(bootstrapProgress))", category: .session)
|
|
||||||
}
|
|
||||||
isReady = ready
|
|
||||||
if ready {
|
|
||||||
NotificationCenter.default.post(name: .TorDidBecomeReady, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPathMonitorIfNeeded() {
|
|
||||||
#if canImport(Network)
|
|
||||||
guard pathMonitor == nil else { return }
|
|
||||||
let monitor = NWPathMonitor()
|
|
||||||
pathMonitor = monitor
|
|
||||||
let queue = DispatchQueue(label: "TorPathMonitor")
|
|
||||||
monitor.pathUpdateHandler = { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if self.isAppForeground {
|
|
||||||
self.pokeTorOnPathChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
monitor.start(queue: queue)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pokeTorOnPathChange() {
|
|
||||||
// Skip if we recently restarted
|
|
||||||
if let last = lastRestartAt, Date().timeIntervalSince(last) < 3.0 {
|
|
||||||
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - recent restart", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Skip during initial startup grace period (15s) to avoid race conditions
|
|
||||||
if let started = startedAt, Date().timeIntervalSince(started) < 15.0 {
|
|
||||||
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - startup grace period (\(Int(Date().timeIntervalSince(started)))s)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isStarting || restarting {
|
|
||||||
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - isStarting=\(isStarting) restarting=\(restarting)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isReady { return }
|
|
||||||
SecureLogger.debug("TorManager: pokeTorOnPathChange() - Arti not ready, initiating recovery", category: .session)
|
|
||||||
ensureRunningOnForeground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Start policy configuration
|
|
||||||
extension TorManager {
|
|
||||||
@MainActor
|
|
||||||
public func setAutoStartAllowed(_ allow: Bool) {
|
|
||||||
allowAutoStart = allow
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public func isAutoStartAllowed() -> Bool { allowAutoStart }
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "arti-bitchat"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.86"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["staticlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Arti core - minimal features for client-only SOCKS proxy
|
|
||||||
arti-client = { version = "0.38", default-features = false, features = [
|
|
||||||
"tokio",
|
|
||||||
"rustls",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Async runtime
|
|
||||||
tokio = { version = "1", default-features = false, features = [
|
|
||||||
"rt-multi-thread",
|
|
||||||
"net",
|
|
||||||
"sync",
|
|
||||||
"time",
|
|
||||||
"macros",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Tor runtime compatibility
|
|
||||||
tor-rtcompat = { version = "0.38", default-features = false, features = ["tokio"] }
|
|
||||||
|
|
||||||
# FFI utilities
|
|
||||||
libc = "0.2"
|
|
||||||
once_cell = "1"
|
|
||||||
|
|
||||||
# Logging (minimal)
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
language = "C"
|
|
||||||
include_guard = "ARTI_H"
|
|
||||||
no_includes = true
|
|
||||||
sys_includes = ["stdint.h", "stdbool.h"]
|
|
||||||
|
|
||||||
[export]
|
|
||||||
include = ["arti_start", "arti_stop", "arti_is_running", "arti_bootstrap_progress", "arti_bootstrap_summary", "arti_go_dormant", "arti_wake"]
|
|
||||||
|
|
||||||
[fn]
|
|
||||||
args = "Auto"
|
|
||||||
|
|
||||||
[parse]
|
|
||||||
parse_deps = false
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
//! arti-bitchat: Minimal FFI wrapper around arti-client for BitChat
|
|
||||||
//!
|
|
||||||
//! Provides a C-compatible interface for embedding Arti (Rust Tor) in iOS/macOS apps.
|
|
||||||
//! Exposes a SOCKS5 proxy on localhost that Swift code can route traffic through.
|
|
||||||
|
|
||||||
use std::ffi::{c_char, c_int, CStr};
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use arti_client::TorClient;
|
|
||||||
use once_cell::sync::OnceCell;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
use tokio::sync::oneshot;
|
|
||||||
use tor_rtcompat::PreferredRuntime;
|
|
||||||
|
|
||||||
mod socks;
|
|
||||||
|
|
||||||
/// Global state for the Arti instance
|
|
||||||
struct ArtiState {
|
|
||||||
/// Tokio runtime (owned, single instance)
|
|
||||||
runtime: Runtime,
|
|
||||||
/// Shutdown signal sender
|
|
||||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
|
||||||
/// TorClient handle for status queries
|
|
||||||
client: Option<Arc<TorClient<PreferredRuntime>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static ARTI_STATE: OnceCell<Mutex<ArtiState>> = OnceCell::new();
|
|
||||||
static BOOTSTRAP_PROGRESS: AtomicI32 = AtomicI32::new(0);
|
|
||||||
static IS_RUNNING: AtomicBool = AtomicBool::new(false);
|
|
||||||
static BOOTSTRAP_SUMMARY: Mutex<String> = Mutex::new(String::new());
|
|
||||||
|
|
||||||
/// Initialize the global state with a new runtime
|
|
||||||
fn init_state() -> Result<(), &'static str> {
|
|
||||||
ARTI_STATE.get_or_try_init(|| -> Result<Mutex<ArtiState>, &'static str> {
|
|
||||||
let runtime = Runtime::new().map_err(|_| "Failed to create tokio runtime")?;
|
|
||||||
Ok(Mutex::new(ArtiState {
|
|
||||||
runtime,
|
|
||||||
shutdown_tx: None,
|
|
||||||
client: None,
|
|
||||||
}))
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start Arti with a SOCKS5 proxy.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `data_dir` - Path to data directory for Tor state (C string)
|
|
||||||
/// * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * 0 on success
|
|
||||||
/// * -1 if already running
|
|
||||||
/// * -2 if data_dir is invalid
|
|
||||||
/// * -3 if runtime initialization failed
|
|
||||||
/// * -4 if bootstrap failed
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_start(data_dir: *const c_char, socks_port: u16) -> c_int {
|
|
||||||
// Check if already running
|
|
||||||
if IS_RUNNING.load(Ordering::SeqCst) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse data directory
|
|
||||||
let data_path = match unsafe { CStr::from_ptr(data_dir) }.to_str() {
|
|
||||||
Ok(s) => PathBuf::from(s),
|
|
||||||
Err(_) => return -2,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize runtime if needed
|
|
||||||
if let Err(_) = init_state() {
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = match ARTI_STATE.get() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return -3,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut guard = match state.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return -3,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create shutdown channel
|
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
||||||
guard.shutdown_tx = Some(shutdown_tx);
|
|
||||||
|
|
||||||
let socks_addr: SocketAddr = format!("127.0.0.1:{}", socks_port)
|
|
||||||
.parse()
|
|
||||||
.expect("valid addr");
|
|
||||||
|
|
||||||
// Spawn the main Arti task
|
|
||||||
let data_path_clone = data_path.clone();
|
|
||||||
guard.runtime.spawn(async move {
|
|
||||||
match run_arti(data_path_clone, socks_addr, shutdown_rx).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!("Arti shutdown cleanly");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Arti error: {}", e);
|
|
||||||
update_summary(&format!("Error: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IS_RUNNING.store(false, Ordering::SeqCst);
|
|
||||||
BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);
|
|
||||||
});
|
|
||||||
|
|
||||||
IS_RUNNING.store(true, Ordering::SeqCst);
|
|
||||||
BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);
|
|
||||||
update_summary("Starting...");
|
|
||||||
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Arti gracefully.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * 0 on success
|
|
||||||
/// * -1 if not running
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_stop() -> c_int {
|
|
||||||
if !IS_RUNNING.load(Ordering::SeqCst) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = match ARTI_STATE.get() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut guard = match state.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send shutdown signal
|
|
||||||
if let Some(tx) = guard.shutdown_tx.take() {
|
|
||||||
let _ = tx.send(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear client reference
|
|
||||||
guard.client = None;
|
|
||||||
|
|
||||||
// Give async tasks time to complete
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
||||||
|
|
||||||
IS_RUNNING.store(false, Ordering::SeqCst);
|
|
||||||
BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);
|
|
||||||
update_summary("");
|
|
||||||
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if Arti is currently running.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * 1 if running
|
|
||||||
/// * 0 if not running
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_is_running() -> c_int {
|
|
||||||
if IS_RUNNING.load(Ordering::SeqCst) {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current bootstrap progress (0-100).
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_bootstrap_progress() -> c_int {
|
|
||||||
BOOTSTRAP_PROGRESS.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current bootstrap summary string.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `buf` - Buffer to write the summary into
|
|
||||||
/// * `len` - Length of the buffer
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * Number of bytes written (not including null terminator)
|
|
||||||
/// * -1 if buffer is null or too small
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_bootstrap_summary(buf: *mut c_char, len: c_int) -> c_int {
|
|
||||||
if buf.is_null() || len <= 0 {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary = match BOOTSTRAP_SUMMARY.lock() {
|
|
||||||
Ok(s) => s.clone(),
|
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let bytes = summary.as_bytes();
|
|
||||||
let copy_len = std::cmp::min(bytes.len(), (len - 1) as usize);
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);
|
|
||||||
*buf.add(copy_len) = 0; // null terminator
|
|
||||||
}
|
|
||||||
|
|
||||||
copy_len as c_int
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal Arti to go dormant (reduce resource usage).
|
|
||||||
/// This is a hint; Arti may not fully support dormant mode yet.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * 0 on success
|
|
||||||
/// * -1 if not running
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_go_dormant() -> c_int {
|
|
||||||
if !IS_RUNNING.load(Ordering::SeqCst) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
// Arti doesn't have explicit dormant mode yet, but we can note the intent
|
|
||||||
update_summary("Dormant");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal Arti to wake from dormant mode.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * 0 on success
|
|
||||||
/// * -1 if not running
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn arti_wake() -> c_int {
|
|
||||||
if !IS_RUNNING.load(Ordering::SeqCst) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
update_summary("Active");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_summary(s: &str) {
|
|
||||||
if let Ok(mut guard) = BOOTSTRAP_SUMMARY.lock() {
|
|
||||||
guard.clear();
|
|
||||||
guard.push_str(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main async entry point for Arti
|
|
||||||
async fn run_arti(
|
|
||||||
data_dir: PathBuf,
|
|
||||||
socks_addr: SocketAddr,
|
|
||||||
mut shutdown_rx: oneshot::Receiver<()>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
// Ensure data directory exists
|
|
||||||
std::fs::create_dir_all(&data_dir)?;
|
|
||||||
|
|
||||||
update_summary("Configuring...");
|
|
||||||
|
|
||||||
// Build Arti configuration with custom directories
|
|
||||||
let cache_dir = data_dir.join("cache");
|
|
||||||
let state_dir = data_dir.join("state");
|
|
||||||
|
|
||||||
// Use from_directories which sets up storage correctly
|
|
||||||
use arti_client::config::TorClientConfigBuilder;
|
|
||||||
let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
update_summary("Bootstrapping...");
|
|
||||||
|
|
||||||
// Create and bootstrap the Tor client
|
|
||||||
let client = TorClient::create_bootstrapped(config).await?;
|
|
||||||
let client = Arc::new(client);
|
|
||||||
|
|
||||||
// Store client reference for status queries
|
|
||||||
if let Some(state) = ARTI_STATE.get() {
|
|
||||||
if let Ok(mut guard) = state.lock() {
|
|
||||||
guard.client = Some(client.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark bootstrap complete
|
|
||||||
BOOTSTRAP_PROGRESS.store(100, Ordering::SeqCst);
|
|
||||||
update_summary("Ready");
|
|
||||||
|
|
||||||
// Bind SOCKS listener
|
|
||||||
let listener = TcpListener::bind(socks_addr).await?;
|
|
||||||
tracing::info!("SOCKS5 proxy listening on {}", socks_addr);
|
|
||||||
|
|
||||||
// Accept connections until shutdown
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
accept_result = listener.accept() => {
|
|
||||||
match accept_result {
|
|
||||||
Ok((stream, peer_addr)) => {
|
|
||||||
let client = client.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = socks::handle_socks_connection(stream, peer_addr, client).await {
|
|
||||||
tracing::debug!("SOCKS connection error from {}: {}", peer_addr, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Accept error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = &mut shutdown_rx => {
|
|
||||||
tracing::info!("Shutdown signal received");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_summary("Shutting down...");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
//! SOCKS5 protocol handler for Arti
|
|
||||||
//!
|
|
||||||
//! Implements a minimal SOCKS5 server that forwards connections through Tor.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use arti_client::{TorClient, IntoTorAddr};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tor_rtcompat::PreferredRuntime;
|
|
||||||
|
|
||||||
// SOCKS5 constants
|
|
||||||
const SOCKS5_VERSION: u8 = 0x05;
|
|
||||||
const SOCKS5_AUTH_NONE: u8 = 0x00;
|
|
||||||
const SOCKS5_CMD_CONNECT: u8 = 0x01;
|
|
||||||
const SOCKS5_ATYP_IPV4: u8 = 0x01;
|
|
||||||
const SOCKS5_ATYP_DOMAIN: u8 = 0x03;
|
|
||||||
const SOCKS5_ATYP_IPV6: u8 = 0x04;
|
|
||||||
const SOCKS5_REP_SUCCESS: u8 = 0x00;
|
|
||||||
const SOCKS5_REP_FAILURE: u8 = 0x01;
|
|
||||||
const SOCKS5_REP_CONN_REFUSED: u8 = 0x05;
|
|
||||||
|
|
||||||
/// Handle a single SOCKS5 connection
|
|
||||||
pub async fn handle_socks_connection(
|
|
||||||
mut stream: TcpStream,
|
|
||||||
peer_addr: SocketAddr,
|
|
||||||
client: Arc<TorClient<PreferredRuntime>>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
// --- Greeting ---
|
|
||||||
// Client sends: VER | NMETHODS | METHODS
|
|
||||||
let mut greeting = [0u8; 2];
|
|
||||||
stream.read_exact(&mut greeting).await?;
|
|
||||||
|
|
||||||
if greeting[0] != SOCKS5_VERSION {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"Not SOCKS5",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let nmethods = greeting[1] as usize;
|
|
||||||
let mut methods = vec![0u8; nmethods];
|
|
||||||
stream.read_exact(&mut methods).await?;
|
|
||||||
|
|
||||||
// We only support no-auth
|
|
||||||
if !methods.contains(&SOCKS5_AUTH_NONE) {
|
|
||||||
// Send failure: no acceptable methods
|
|
||||||
stream.write_all(&[SOCKS5_VERSION, 0xFF]).await?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::PermissionDenied,
|
|
||||||
"No acceptable auth methods",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept no-auth
|
|
||||||
stream.write_all(&[SOCKS5_VERSION, SOCKS5_AUTH_NONE]).await?;
|
|
||||||
|
|
||||||
// --- Request ---
|
|
||||||
// Client sends: VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT
|
|
||||||
let mut request_header = [0u8; 4];
|
|
||||||
stream.read_exact(&mut request_header).await?;
|
|
||||||
|
|
||||||
if request_header[0] != SOCKS5_VERSION {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"Invalid SOCKS5 request version",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmd = request_header[1];
|
|
||||||
let atyp = request_header[3];
|
|
||||||
|
|
||||||
if cmd != SOCKS5_CMD_CONNECT {
|
|
||||||
// We only support CONNECT
|
|
||||||
send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::Unsupported,
|
|
||||||
"Only CONNECT supported",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse destination address
|
|
||||||
let (dest_host, dest_port) = match atyp {
|
|
||||||
SOCKS5_ATYP_IPV4 => {
|
|
||||||
let mut addr = [0u8; 4];
|
|
||||||
stream.read_exact(&mut addr).await?;
|
|
||||||
let mut port_buf = [0u8; 2];
|
|
||||||
stream.read_exact(&mut port_buf).await?;
|
|
||||||
let port = u16::from_be_bytes(port_buf);
|
|
||||||
let host = format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3]);
|
|
||||||
(host, port)
|
|
||||||
}
|
|
||||||
SOCKS5_ATYP_DOMAIN => {
|
|
||||||
let mut len_buf = [0u8; 1];
|
|
||||||
stream.read_exact(&mut len_buf).await?;
|
|
||||||
let len = len_buf[0] as usize;
|
|
||||||
let mut domain = vec![0u8; len];
|
|
||||||
stream.read_exact(&mut domain).await?;
|
|
||||||
let mut port_buf = [0u8; 2];
|
|
||||||
stream.read_exact(&mut port_buf).await?;
|
|
||||||
let port = u16::from_be_bytes(port_buf);
|
|
||||||
let host = String::from_utf8_lossy(&domain).to_string();
|
|
||||||
(host, port)
|
|
||||||
}
|
|
||||||
SOCKS5_ATYP_IPV6 => {
|
|
||||||
let mut addr = [0u8; 16];
|
|
||||||
stream.read_exact(&mut addr).await?;
|
|
||||||
let mut port_buf = [0u8; 2];
|
|
||||||
stream.read_exact(&mut port_buf).await?;
|
|
||||||
let port = u16::from_be_bytes(port_buf);
|
|
||||||
// Format IPv6 address
|
|
||||||
let segments: Vec<String> = addr
|
|
||||||
.chunks(2)
|
|
||||||
.map(|c| format!("{:02x}{:02x}", c[0], c[1]))
|
|
||||||
.collect();
|
|
||||||
let host = format!("[{}]", segments.join(":"));
|
|
||||||
(host, port)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"Unsupported address type",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("SOCKS5 CONNECT from {} to {}:{}", peer_addr, dest_host, dest_port);
|
|
||||||
|
|
||||||
// Connect through Tor
|
|
||||||
let tor_addr = format!("{}:{}", dest_host, dest_port);
|
|
||||||
let tor_addr = match tor_addr.as_str().into_tor_addr() {
|
|
||||||
Ok(a) => a,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!("Invalid Tor address: {}", e);
|
|
||||||
send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("Invalid Tor address: {}", e),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let tor_stream = match client.connect(tor_addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!("Tor connect failed: {}", e);
|
|
||||||
send_reply(&mut stream, SOCKS5_REP_CONN_REFUSED).await?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::ConnectionRefused,
|
|
||||||
e.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send success reply
|
|
||||||
// Reply: VER | REP | RSV | ATYP | BND.ADDR | BND.PORT
|
|
||||||
// We use 0.0.0.0:0 as the bound address since we're proxying
|
|
||||||
let reply = [
|
|
||||||
SOCKS5_VERSION,
|
|
||||||
SOCKS5_REP_SUCCESS,
|
|
||||||
0x00, // RSV
|
|
||||||
SOCKS5_ATYP_IPV4,
|
|
||||||
0, 0, 0, 0, // BND.ADDR
|
|
||||||
0, 0, // BND.PORT
|
|
||||||
];
|
|
||||||
stream.write_all(&reply).await?;
|
|
||||||
|
|
||||||
// Bidirectional copy
|
|
||||||
let (mut client_read, mut client_write) = stream.into_split();
|
|
||||||
let (mut tor_read, mut tor_write) = tor_stream.split();
|
|
||||||
|
|
||||||
let client_to_tor = async {
|
|
||||||
tokio::io::copy(&mut client_read, &mut tor_write).await
|
|
||||||
};
|
|
||||||
let tor_to_client = async {
|
|
||||||
tokio::io::copy(&mut tor_read, &mut client_write).await
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
result = client_to_tor => {
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::debug!("Client to Tor copy error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = tor_to_client => {
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::debug!("Tor to client copy error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_reply(stream: &mut TcpStream, rep: u8) -> io::Result<()> {
|
|
||||||
let reply = [
|
|
||||||
SOCKS5_VERSION,
|
|
||||||
rep,
|
|
||||||
0x00, // RSV
|
|
||||||
SOCKS5_ATYP_IPV4,
|
|
||||||
0, 0, 0, 0, // BND.ADDR
|
|
||||||
0, 0, // BND.PORT
|
|
||||||
];
|
|
||||||
stream.write_all(&reply).await
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Build arti-bitchat for iOS/macOS with aggressive size optimization
|
|
||||||
#
|
|
||||||
# Output: Frameworks/arti.xcframework containing static libraries for:
|
|
||||||
# - aarch64-apple-ios (iOS device)
|
|
||||||
# - aarch64-apple-ios-sim (iOS simulator, Apple Silicon)
|
|
||||||
# - x86_64-apple-ios (iOS simulator, Intel - optional)
|
|
||||||
# - aarch64-apple-darwin (macOS)
|
|
||||||
#
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
CRATE_NAME="arti-bitchat"
|
|
||||||
LIB_NAME="libarti_bitchat.a"
|
|
||||||
FRAMEWORK_NAME="arti"
|
|
||||||
OUTPUT_DIR="$SCRIPT_DIR/Frameworks"
|
|
||||||
|
|
||||||
# Targets to build
|
|
||||||
TARGETS=(
|
|
||||||
"aarch64-apple-ios" # iOS device
|
|
||||||
"aarch64-apple-ios-sim" # iOS simulator (Apple Silicon)
|
|
||||||
"aarch64-apple-darwin" # macOS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
|
||||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
||||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
||||||
|
|
||||||
# Check prerequisites
|
|
||||||
check_prerequisites() {
|
|
||||||
log_info "Checking prerequisites..."
|
|
||||||
|
|
||||||
if ! command -v rustc &> /dev/null; then
|
|
||||||
log_error "Rust is not installed. Please install via rustup."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v cargo &> /dev/null; then
|
|
||||||
log_error "Cargo is not installed. Please install via rustup."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check/install targets
|
|
||||||
for target in "${TARGETS[@]}"; do
|
|
||||||
if ! rustup target list --installed | grep -q "$target"; then
|
|
||||||
log_info "Installing target: $target"
|
|
||||||
rustup target add "$target"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Install cbindgen if needed
|
|
||||||
if ! command -v cbindgen &> /dev/null; then
|
|
||||||
log_info "Installing cbindgen..."
|
|
||||||
cargo install cbindgen
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_info "Prerequisites OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set up aggressive size optimization flags and deployment targets
|
|
||||||
setup_rustflags() {
|
|
||||||
local target="$1"
|
|
||||||
|
|
||||||
# Base flags for size optimization
|
|
||||||
export RUSTFLAGS="-C opt-level=z -C lto=fat -C codegen-units=1 -C panic=abort -C strip=symbols"
|
|
||||||
|
|
||||||
# Set deployment targets to suppress linker warnings about version mismatches
|
|
||||||
case "$target" in
|
|
||||||
*-apple-ios-sim*)
|
|
||||||
export IPHONEOS_DEPLOYMENT_TARGET="16.0"
|
|
||||||
# Simulator uses iPhone SDK but needs the sim target
|
|
||||||
;;
|
|
||||||
*-apple-ios*)
|
|
||||||
export IPHONEOS_DEPLOYMENT_TARGET="16.0"
|
|
||||||
;;
|
|
||||||
*-apple-darwin*)
|
|
||||||
export MACOSX_DEPLOYMENT_TARGET="13.0"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
log_info "RUSTFLAGS: $RUSTFLAGS"
|
|
||||||
log_info "Deployment target: MACOSX=$MACOSX_DEPLOYMENT_TARGET IPHONEOS=$IPHONEOS_DEPLOYMENT_TARGET"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build for a single target
|
|
||||||
build_target() {
|
|
||||||
local target="$1"
|
|
||||||
log_info "Building for target: $target"
|
|
||||||
|
|
||||||
setup_rustflags "$target"
|
|
||||||
|
|
||||||
# Build release
|
|
||||||
cargo build --release --target "$target" -p "$CRATE_NAME"
|
|
||||||
|
|
||||||
# Check output
|
|
||||||
local lib_path="target/$target/release/$LIB_NAME"
|
|
||||||
if [[ -f "$lib_path" ]]; then
|
|
||||||
local size=$(du -h "$lib_path" | cut -f1)
|
|
||||||
log_info "Built $lib_path ($size)"
|
|
||||||
else
|
|
||||||
log_error "Build failed: $lib_path not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create xcframework from built libraries
|
|
||||||
create_xcframework() {
|
|
||||||
log_info "Creating xcframework..."
|
|
||||||
|
|
||||||
local xcframework_path="$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework"
|
|
||||||
|
|
||||||
# Remove existing xcframework
|
|
||||||
rm -rf "$xcframework_path"
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
|
|
||||||
# Build the xcodebuild command
|
|
||||||
local cmd="xcodebuild -create-xcframework"
|
|
||||||
|
|
||||||
for target in "${TARGETS[@]}"; do
|
|
||||||
local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME"
|
|
||||||
if [[ -f "$lib_path" ]]; then
|
|
||||||
# Strip the library for additional size reduction
|
|
||||||
log_info "Stripping $target library..."
|
|
||||||
strip -x "$lib_path" 2>/dev/null || true
|
|
||||||
|
|
||||||
cmd="$cmd -library $lib_path"
|
|
||||||
|
|
||||||
# Add headers if they exist
|
|
||||||
local header_dir="$OUTPUT_DIR/include"
|
|
||||||
if [[ -d "$header_dir" ]]; then
|
|
||||||
cmd="$cmd -headers $header_dir"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log_warn "Skipping missing library: $lib_path"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
cmd="$cmd -output $xcframework_path"
|
|
||||||
|
|
||||||
log_info "Running: $cmd"
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
if [[ -d "$xcframework_path" ]]; then
|
|
||||||
local size=$(du -sh "$xcframework_path" | cut -f1)
|
|
||||||
log_info "Created $xcframework_path ($size)"
|
|
||||||
else
|
|
||||||
log_error "Failed to create xcframework"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate C header using cbindgen
|
|
||||||
generate_header() {
|
|
||||||
log_info "Generating C header..."
|
|
||||||
|
|
||||||
local header_dir="$OUTPUT_DIR/include"
|
|
||||||
local header_path="$header_dir/arti.h"
|
|
||||||
|
|
||||||
mkdir -p "$header_dir"
|
|
||||||
|
|
||||||
# Create cbindgen.toml if it doesn't exist
|
|
||||||
if [[ ! -f "$CRATE_NAME/cbindgen.toml" ]]; then
|
|
||||||
cat > "$CRATE_NAME/cbindgen.toml" << 'EOF'
|
|
||||||
language = "C"
|
|
||||||
include_guard = "ARTI_H"
|
|
||||||
no_includes = true
|
|
||||||
sys_includes = ["stdint.h", "stdbool.h"]
|
|
||||||
|
|
||||||
[export]
|
|
||||||
include = ["arti_start", "arti_stop", "arti_is_running", "arti_bootstrap_progress", "arti_bootstrap_summary", "arti_go_dormant", "arti_wake"]
|
|
||||||
|
|
||||||
[fn]
|
|
||||||
args = "Auto"
|
|
||||||
|
|
||||||
[parse]
|
|
||||||
parse_deps = false
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
cbindgen --config "$CRATE_NAME/cbindgen.toml" \
|
|
||||||
--crate "$CRATE_NAME" \
|
|
||||||
--output "$header_path"
|
|
||||||
|
|
||||||
if [[ -f "$header_path" ]]; then
|
|
||||||
log_info "Generated $header_path"
|
|
||||||
cat "$header_path"
|
|
||||||
else
|
|
||||||
log_warn "cbindgen did not generate header, creating manually..."
|
|
||||||
# Fallback: create header manually
|
|
||||||
cat > "$header_path" << 'EOF'
|
|
||||||
#ifndef ARTI_H
|
|
||||||
#define ARTI_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Arti with a SOCKS5 proxy.
|
|
||||||
*
|
|
||||||
* @param data_dir Path to data directory for Tor state (C string)
|
|
||||||
* @param socks_port Port for SOCKS5 proxy (e.g., 39050)
|
|
||||||
* @return 0 on success, negative on error
|
|
||||||
*/
|
|
||||||
int32_t arti_start(const char *data_dir, uint16_t socks_port);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Arti gracefully.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_stop(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Arti is currently running.
|
|
||||||
*
|
|
||||||
* @return 1 if running, 0 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_is_running(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap progress (0-100).
|
|
||||||
*
|
|
||||||
* @return Progress percentage
|
|
||||||
*/
|
|
||||||
int32_t arti_bootstrap_progress(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current bootstrap summary string.
|
|
||||||
*
|
|
||||||
* @param buf Buffer to write the summary into
|
|
||||||
* @param len Length of the buffer
|
|
||||||
* @return Number of bytes written, -1 on error
|
|
||||||
*/
|
|
||||||
int32_t arti_bootstrap_summary(char *buf, int32_t len);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to go dormant (reduce resource usage).
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_go_dormant(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal Arti to wake from dormant mode.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 if not running
|
|
||||||
*/
|
|
||||||
int32_t arti_wake(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* ARTI_H */
|
|
||||||
EOF
|
|
||||||
log_info "Created manual header at $header_path"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Print size report
|
|
||||||
print_size_report() {
|
|
||||||
log_info "=== Size Report ==="
|
|
||||||
for target in "${TARGETS[@]}"; do
|
|
||||||
local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME"
|
|
||||||
if [[ -f "$lib_path" ]]; then
|
|
||||||
local size=$(du -h "$lib_path" | cut -f1)
|
|
||||||
echo " $target: $size"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
local xcframework_path="$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework"
|
|
||||||
if [[ -d "$xcframework_path" ]]; then
|
|
||||||
local total_size=$(du -sh "$xcframework_path" | cut -f1)
|
|
||||||
echo " xcframework total: $total_size"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main
|
|
||||||
main() {
|
|
||||||
log_info "Building arti-bitchat for iOS/macOS"
|
|
||||||
log_info "=================================="
|
|
||||||
|
|
||||||
check_prerequisites
|
|
||||||
generate_header
|
|
||||||
|
|
||||||
for target in "${TARGETS[@]}"; do
|
|
||||||
build_target "$target"
|
|
||||||
done
|
|
||||||
|
|
||||||
create_xcframework
|
|
||||||
print_size_report
|
|
||||||
|
|
||||||
log_info "Build complete!"
|
|
||||||
log_info "xcframework: $OUTPUT_DIR/$FRAMEWORK_NAME.xcframework"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run
|
|
||||||
main "$@"
|
|
||||||
@@ -19,5 +19,4 @@ public extension OSLog {
|
|||||||
static let session = OSLog(subsystem: subsystem, category: "session")
|
static let session = OSLog(subsystem: subsystem, category: "session")
|
||||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||||
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||||
static let sync = OSLog(subsystem: subsystem, category: "sync")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
**BitChat Tor Build Notes**
|
||||||
|
|
||||||
|
- Date: See repo history for the commit you pulled
|
||||||
|
- Output: `tor-nolzma.xcframework` (static, C-only)
|
||||||
|
- Platforms: iOS device (arm64), iOS simulator (arm64), macOS (arm64)
|
||||||
|
- Goal: Minimize binary size while retaining client functionality
|
||||||
|
|
||||||
|
**Overview**
|
||||||
|
- We built a minimal Tor static xcframework with LZMA disabled to reduce size and complexity.
|
||||||
|
- The artifact contains only the C libraries (Tor + libevent + OpenSSL) and their headers. Objective‑C wrappers (`TORThread`, `TORController`, etc.) are not compiled into this minimal artifact to keep size down.
|
||||||
|
- This xcframework is suitable for iOS and macOS targets that link the Objective‑C wrappers as source (or use CocoaPods to bring them in).
|
||||||
|
|
||||||
|
**Component Versions**
|
||||||
|
- Tor: 0.4.8.17
|
||||||
|
- libevent: 2.1.12
|
||||||
|
- OpenSSL: 3.5.1
|
||||||
|
- liblzma: not linked (intentionally disabled)
|
||||||
|
|
||||||
|
**Build Environment**
|
||||||
|
- Xcode with iOS and macOS SDKs
|
||||||
|
- Homebrew tools: `autoconf`, `automake`, `libtool`, `gettext`
|
||||||
|
- Install prerequisites from repo root: `brew bundle`
|
||||||
|
|
||||||
|
**Command Used**
|
||||||
|
- Minimal build (nolzma), with persistent logs: `./build-xcframework.sh -md`
|
||||||
|
- `-m` = minimal mode
|
||||||
|
- `-d` = keep build dir and logs under `build/`
|
||||||
|
|
||||||
|
**What Minimal Mode Does**
|
||||||
|
- Targets: `iphoneos/arm64`, `iphonesimulator/arm64`, `macosx/arm64`.
|
||||||
|
- Disables LZMA in Tor (`--enable-lzma=no`) and removes zstd.
|
||||||
|
- Trims OpenSSL features: `no-zlib no-comp no-ssl3 no-tls1 no-tls1_1 no-dtls no-srp no-psk no-weak-ssl-ciphers no-engine no-ocsp`.
|
||||||
|
- Compiles with size-first flags: `-Os -ffunction-sections -fdata-sections`; bitcode is not embedded.
|
||||||
|
- Statically links Tor, libevent, and OpenSSL into a single library per slice inside the framework.
|
||||||
|
- Copies public headers from Tor/libevent/OpenSSL into the framework `Headers` directory.
|
||||||
|
|
||||||
|
**Resulting Slices (approx sizes)**
|
||||||
|
- Folder size: ~73 MB (`tor-nolzma.xcframework`)
|
||||||
|
- Binaries (non-fat, measured on this build):
|
||||||
|
- iOS arm64 (device): ~16.49 MB
|
||||||
|
- iOS arm64 (simulator): ~15.32 MB
|
||||||
|
- macOS arm64: ~15.60 MB
|
||||||
|
|
||||||
|
Note: Sizes vary slightly by Xcode/SDK versions and environment.
|
||||||
|
|
||||||
|
**Integrating in BitChat**
|
||||||
|
- Add `tor-nolzma.xcframework` to your app target(s). Xcode will select the correct slice for device/simulator/macOS.
|
||||||
|
- Link `libz.tbd` (Tor depends on zlib).
|
||||||
|
- Keep app link-time stripping enabled for best results:
|
||||||
|
- Other Linker Flags: add `-dead_strip`
|
||||||
|
- Avoid `-ObjC` if possible (prevents dead stripping)
|
||||||
|
- Consider enabling ThinLTO/LTO in the app for further size gains
|
||||||
|
- Objective‑C API (wrappers):
|
||||||
|
- Not included in this minimal xcframework. Use one of:
|
||||||
|
- CocoaPods: `Tor/CTor-NoLZMA` subspec (brings `TORThread`, `TORController` sources + links the xcframework), or
|
||||||
|
- Vendor the ObjC sources from `Tor/Classes/CTor` and `Tor/Classes/Core` directly into your project.
|
||||||
|
|
||||||
|
**Rebuilding**
|
||||||
|
- Ensure prerequisites: `brew bundle`
|
||||||
|
- Minimal nolzma, iOS+sim+macOS: `./build-xcframework.sh -m`
|
||||||
|
- Logs (if `-d`): `build/*.log` and per-component logs like `build/libtor-nolzma-<sdk>-<arch>.log`
|
||||||
|
|
||||||
|
**LZMA Trade‑off (for reference)**
|
||||||
|
- We measured that enabling LZMA adds roughly ~0.25 MB per slice to the binary on this setup. For a 3‑slice xcframework, expect ~0.7–0.8 MB more overall.
|
||||||
|
- If you want the LZMA variant with the same minimal trimming: `./build-xcframework.sh -Md` (outputs `tor.xcframework`).
|
||||||
|
|
||||||
|
**Key Flags (for auditing)**
|
||||||
|
- OpenSSL `./Configure` adds: `no-shared` and, in minimal modes, `no-zlib no-comp no-ssl3 no-tls1 no-tls1_1 no-dtls no-srp no-psk no-weak-ssl-ciphers no-engine no-ocsp`
|
||||||
|
- libevent `./configure`: `--disable-openssl --disable-samples --disable-regress --enable-static --disable-shared`
|
||||||
|
- Tor `./configure` (highlights):
|
||||||
|
- `--enable-pic --disable-module-relay --disable-module-dirauth --disable-unittests`
|
||||||
|
- `--enable-static-openssl --enable-static-libevent`
|
||||||
|
- `--disable-asciidoc --disable-manpage --disable-html-manual --disable-zstd`
|
||||||
|
- `--enable-lzma=no` (in this build)
|
||||||
|
- Compiler flags: `-Os -ffunction-sections -fdata-sections`; no bitcode
|
||||||
|
- Minimum OS: iOS 12.0, macOS 10.13
|
||||||
|
|
||||||
|
**Verification Tips**
|
||||||
|
- Check slices: `lipo -info tor-nolzma.xcframework/*/tor-nolzma.framework/tor-nolzma`
|
||||||
|
- Ensure headers present: `ls tor-nolzma.xcframework/*/tor-nolzma.framework/Headers`
|
||||||
|
- Link test: build a small app and add `-dead_strip`; confirm successful run and circuit establishment via control port.
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- This minimal build avoids bundling large GeoIP resources. If you need GeoIP, embed the GeoIP bundle (or use the `Tor/GeoIP-NoLZMA` subspec) and set `TORConfiguration.geoipFile`/`geoip6File`.
|
||||||
|
- Static linking maximizes the app’s ability to dead‑strip unused code across the boundary.
|
||||||
|
|
||||||
+18
-24
@@ -6,13 +6,11 @@
|
|||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>BinaryPath</key>
|
<key>BinaryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>tor-nolzma.framework/tor-nolzma</string>
|
||||||
<key>HeadersPath</key>
|
|
||||||
<string>Headers</string>
|
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>macos-arm64</string>
|
<string>macos-arm64</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>tor-nolzma.framework</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
@@ -22,29 +20,11 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>BinaryPath</key>
|
<key>BinaryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>tor-nolzma.framework/tor-nolzma</string>
|
||||||
<key>HeadersPath</key>
|
|
||||||
<string>Headers</string>
|
|
||||||
<key>LibraryIdentifier</key>
|
|
||||||
<string>ios-arm64</string>
|
|
||||||
<key>LibraryPath</key>
|
|
||||||
<string>libarti_bitchat.a</string>
|
|
||||||
<key>SupportedArchitectures</key>
|
|
||||||
<array>
|
|
||||||
<string>arm64</string>
|
|
||||||
</array>
|
|
||||||
<key>SupportedPlatform</key>
|
|
||||||
<string>ios</string>
|
|
||||||
</dict>
|
|
||||||
<dict>
|
|
||||||
<key>BinaryPath</key>
|
|
||||||
<string>libarti_bitchat.a</string>
|
|
||||||
<key>HeadersPath</key>
|
|
||||||
<string>Headers</string>
|
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>ios-arm64-simulator</string>
|
<string>ios-arm64-simulator</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libarti_bitchat.a</string>
|
<string>tor-nolzma.framework</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
@@ -54,6 +34,20 @@
|
|||||||
<key>SupportedPlatformVariant</key>
|
<key>SupportedPlatformVariant</key>
|
||||||
<string>simulator</string>
|
<string>simulator</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>BinaryPath</key>
|
||||||
|
<string>tor-nolzma.framework/tor-nolzma</string>
|
||||||
|
<key>LibraryIdentifier</key>
|
||||||
|
<string>ios-arm64</string>
|
||||||
|
<key>LibraryPath</key>
|
||||||
|
<string>tor-nolzma.framework</string>
|
||||||
|
<key>SupportedArchitectures</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>SupportedPlatform</key>
|
||||||
|
<string>ios</string>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>XFWK</string>
|
<string>XFWK</string>
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
/* Copyright (c) 2001 Matej Pfajfar.
|
||||||
|
* Copyright (c) 2001-2004, Roger Dingledine.
|
||||||
|
* Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson.
|
||||||
|
* Copyright (c) 2007-2021, The Tor Project, Inc. */
|
||||||
|
/* See LICENSE for licensing information */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file config.h
|
||||||
|
* \brief Header file for config.c.
|
||||||
|
**/
|
||||||
|
|
||||||
|
#ifndef TOR_CONFIG_H
|
||||||
|
#define TOR_CONFIG_H
|
||||||
|
|
||||||
|
#include "app/config/or_options_st.h"
|
||||||
|
#include "lib/testsupport/testsupport.h"
|
||||||
|
#include "app/config/quiet_level.h"
|
||||||
|
|
||||||
|
#if defined(__FreeBSD__) || defined(__FreeBSD_kernel__) || defined(DARWIN)
|
||||||
|
#define KERNEL_MAY_SUPPORT_IPFW
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/** Lowest allowable value for HeartbeatPeriod; if this is too low, we might
|
||||||
|
* expose more information than we're comfortable with. */
|
||||||
|
#define MIN_HEARTBEAT_PERIOD (30*60)
|
||||||
|
|
||||||
|
/** Maximum default value for MaxMemInQueues, in bytes. */
|
||||||
|
#if SIZEOF_VOID_P >= 8
|
||||||
|
#define MAX_DEFAULT_MEMORY_QUEUE_SIZE (UINT64_C(8) << 30)
|
||||||
|
#else
|
||||||
|
#define MAX_DEFAULT_MEMORY_QUEUE_SIZE (UINT64_C(2) << 30)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
MOCK_DECL(const or_options_t *, get_options, (void));
|
||||||
|
MOCK_DECL(or_options_t *, get_options_mutable, (void));
|
||||||
|
int set_options(or_options_t *new_val, char **msg);
|
||||||
|
void config_free_all(void);
|
||||||
|
const char *safe_str_client(const char *address);
|
||||||
|
const char *safe_str(const char *address);
|
||||||
|
const char *escaped_safe_str_client(const char *address);
|
||||||
|
const char *escaped_safe_str(const char *address);
|
||||||
|
void init_protocol_warning_severity_level(void);
|
||||||
|
int get_protocol_warning_severity_level(void);
|
||||||
|
|
||||||
|
#define LOG_PROTOCOL_WARN (get_protocol_warning_severity_level())
|
||||||
|
|
||||||
|
/** Pattern for backing up configuration files */
|
||||||
|
#define CONFIG_BACKUP_PATTERN "%s.orig.1"
|
||||||
|
|
||||||
|
/** An error from options_trial_assign() or options_init_from_string(). */
|
||||||
|
typedef enum setopt_err_t {
|
||||||
|
SETOPT_OK = 0,
|
||||||
|
SETOPT_ERR_MISC = -1,
|
||||||
|
SETOPT_ERR_PARSE = -2,
|
||||||
|
SETOPT_ERR_TRANSITION = -3,
|
||||||
|
SETOPT_ERR_SETTING = -4,
|
||||||
|
} setopt_err_t;
|
||||||
|
setopt_err_t options_trial_assign(struct config_line_t *list, unsigned flags,
|
||||||
|
char **msg);
|
||||||
|
|
||||||
|
void options_init(or_options_t *options);
|
||||||
|
|
||||||
|
#define OPTIONS_DUMP_MINIMAL 1
|
||||||
|
#define OPTIONS_DUMP_ALL 2
|
||||||
|
char *options_dump(const or_options_t *options, int how_to_dump);
|
||||||
|
int options_init_from_torrc(int argc, char **argv);
|
||||||
|
setopt_err_t options_init_from_string(const char *cf_defaults, const char *cf,
|
||||||
|
int command, const char *command_arg, char **msg);
|
||||||
|
int option_is_recognized(const char *key);
|
||||||
|
const char *option_get_canonical_name(const char *key);
|
||||||
|
struct config_line_t *option_get_assignment(const or_options_t *options,
|
||||||
|
const char *key);
|
||||||
|
int options_save_current(void);
|
||||||
|
const char *get_torrc_fname(int defaults_fname);
|
||||||
|
typedef enum {
|
||||||
|
DIRROOT_DATADIR,
|
||||||
|
DIRROOT_CACHEDIR,
|
||||||
|
DIRROOT_KEYDIR
|
||||||
|
} directory_root_t;
|
||||||
|
|
||||||
|
MOCK_DECL(char *,
|
||||||
|
options_get_dir_fname2_suffix,
|
||||||
|
(const or_options_t *options,
|
||||||
|
directory_root_t roottype,
|
||||||
|
const char *sub1, const char *sub2,
|
||||||
|
const char *suffix));
|
||||||
|
|
||||||
|
/* These macros wrap options_get_dir_fname2_suffix to provide a more
|
||||||
|
* convenient API for finding filenames that Tor uses inside its storage
|
||||||
|
* They are named according to a pattern:
|
||||||
|
* (options_)?get_(cache|key|data)dir_fname(2)?(_suffix)?
|
||||||
|
*
|
||||||
|
* Macros that begin with options_ take an options argument; the others
|
||||||
|
* work with respect to the global options.
|
||||||
|
*
|
||||||
|
* Each macro works relative to the data directory, the key directory,
|
||||||
|
* or the cache directory, as determined by which one is mentioned.
|
||||||
|
*
|
||||||
|
* Macro variants with "2" in their name take two path components; others
|
||||||
|
* take one.
|
||||||
|
*
|
||||||
|
* Macro variants with "_suffix" at the end take an additional suffix
|
||||||
|
* that gets appended to the end of the file
|
||||||
|
*/
|
||||||
|
#define options_get_datadir_fname2_suffix(options, sub1, sub2, suffix) \
|
||||||
|
options_get_dir_fname2_suffix((options), DIRROOT_DATADIR, \
|
||||||
|
(sub1), (sub2), (suffix))
|
||||||
|
#define options_get_cachedir_fname2_suffix(options, sub1, sub2, suffix) \
|
||||||
|
options_get_dir_fname2_suffix((options), DIRROOT_CACHEDIR, \
|
||||||
|
(sub1), (sub2), (suffix))
|
||||||
|
#define options_get_keydir_fname2_suffix(options, sub1, sub2, suffix) \
|
||||||
|
options_get_dir_fname2_suffix((options), DIRROOT_KEYDIR, \
|
||||||
|
(sub1), (sub2), (suffix))
|
||||||
|
|
||||||
|
#define options_get_datadir_fname(opts,sub1) \
|
||||||
|
options_get_datadir_fname2_suffix((opts),(sub1), NULL, NULL)
|
||||||
|
#define options_get_datadir_fname2(opts,sub1,sub2) \
|
||||||
|
options_get_datadir_fname2_suffix((opts),(sub1), (sub2), NULL)
|
||||||
|
|
||||||
|
#define get_datadir_fname2_suffix(sub1, sub2, suffix) \
|
||||||
|
options_get_datadir_fname2_suffix(get_options(), (sub1), (sub2), (suffix))
|
||||||
|
#define get_datadir_fname(sub1) \
|
||||||
|
get_datadir_fname2_suffix((sub1), NULL, NULL)
|
||||||
|
#define get_datadir_fname2(sub1,sub2) \
|
||||||
|
get_datadir_fname2_suffix((sub1), (sub2), NULL)
|
||||||
|
#define get_datadir_fname_suffix(sub1, suffix) \
|
||||||
|
get_datadir_fname2_suffix((sub1), NULL, (suffix))
|
||||||
|
|
||||||
|
/** DOCDOC */
|
||||||
|
#define options_get_keydir_fname(options, sub1) \
|
||||||
|
options_get_keydir_fname2_suffix((options), (sub1), NULL, NULL)
|
||||||
|
#define get_keydir_fname_suffix(sub1, suffix) \
|
||||||
|
options_get_keydir_fname2_suffix(get_options(), (sub1), NULL, suffix)
|
||||||
|
#define get_keydir_fname(sub1) \
|
||||||
|
options_get_keydir_fname2_suffix(get_options(), (sub1), NULL, NULL)
|
||||||
|
|
||||||
|
#define get_cachedir_fname(sub1) \
|
||||||
|
options_get_cachedir_fname2_suffix(get_options(), (sub1), NULL, NULL)
|
||||||
|
#define get_cachedir_fname_suffix(sub1, suffix) \
|
||||||
|
options_get_cachedir_fname2_suffix(get_options(), (sub1), NULL, (suffix))
|
||||||
|
|
||||||
|
#define safe_str_client(address) \
|
||||||
|
safe_str_client_opts(NULL, address)
|
||||||
|
#define safe_str(address) \
|
||||||
|
safe_str_opts(NULL, address)
|
||||||
|
|
||||||
|
const char * safe_str_client_opts(const or_options_t *options,
|
||||||
|
const char *address);
|
||||||
|
const char * safe_str_opts(const or_options_t *options,
|
||||||
|
const char *address);
|
||||||
|
|
||||||
|
int using_default_dir_authorities(const or_options_t *options);
|
||||||
|
|
||||||
|
int create_keys_directory(const or_options_t *options);
|
||||||
|
|
||||||
|
int check_or_create_data_subdir(const char *subdir);
|
||||||
|
int write_to_data_subdir(const char* subdir, const char* fname,
|
||||||
|
const char* str, const char* descr);
|
||||||
|
|
||||||
|
int get_num_cpus(const or_options_t *options);
|
||||||
|
|
||||||
|
MOCK_DECL(const smartlist_t *,get_configured_ports,(void));
|
||||||
|
int port_binds_ipv4(const port_cfg_t *port);
|
||||||
|
int port_binds_ipv6(const port_cfg_t *port);
|
||||||
|
int portconf_get_first_advertised_port(int listener_type,
|
||||||
|
int address_family);
|
||||||
|
#define portconf_get_primary_dir_port() \
|
||||||
|
(portconf_get_first_advertised_port(CONN_TYPE_DIR_LISTENER, AF_INET))
|
||||||
|
const tor_addr_t *portconf_get_first_advertised_addr(int listener_type,
|
||||||
|
int address_family);
|
||||||
|
int port_exists_by_type_addr_port(int listener_type, const tor_addr_t *addr,
|
||||||
|
int port, int check_wildcard);
|
||||||
|
int port_exists_by_type_addr32h_port(int listener_type, uint32_t addr_ipv4h,
|
||||||
|
int port, int check_wildcard);
|
||||||
|
|
||||||
|
char *get_first_listener_addrport_string(int listener_type);
|
||||||
|
|
||||||
|
int options_need_geoip_info(const or_options_t *options,
|
||||||
|
const char **reason_out);
|
||||||
|
|
||||||
|
int getinfo_helper_config(control_connection_t *conn,
|
||||||
|
const char *question, char **answer,
|
||||||
|
const char **errmsg);
|
||||||
|
|
||||||
|
int init_cookie_authentication(const char *fname, const char *header,
|
||||||
|
int cookie_len, int group_readable,
|
||||||
|
uint8_t **cookie_out, int *cookie_is_set_out);
|
||||||
|
|
||||||
|
or_options_t *options_new(void);
|
||||||
|
|
||||||
|
/** Options settings parsed from the command-line. */
|
||||||
|
typedef struct {
|
||||||
|
/** List of options that can only be set from the command-line */
|
||||||
|
struct config_line_t *cmdline_opts;
|
||||||
|
/** List of other options, to be handled by the general Tor configuration
|
||||||
|
system. */
|
||||||
|
struct config_line_t *other_opts;
|
||||||
|
/** Subcommand that Tor has been told to run */
|
||||||
|
tor_cmdline_mode_t command;
|
||||||
|
/** Argument for the command mode, if any. */
|
||||||
|
const char *command_arg;
|
||||||
|
/** How quiet have we been told to be? */
|
||||||
|
quiet_level_t quiet_level;
|
||||||
|
} parsed_cmdline_t;
|
||||||
|
|
||||||
|
parsed_cmdline_t *config_parse_commandline(int argc, char **argv,
|
||||||
|
int ignore_errors);
|
||||||
|
void parsed_cmdline_free_(parsed_cmdline_t *cmdline);
|
||||||
|
#define parsed_cmdline_free(c) \
|
||||||
|
FREE_AND_NULL(parsed_cmdline_t, parsed_cmdline_free_, (c))
|
||||||
|
|
||||||
|
void config_register_addressmaps(const or_options_t *options);
|
||||||
|
/* XXXX move to connection_edge.h */
|
||||||
|
int addressmap_register_auto(const char *from, const char *to,
|
||||||
|
time_t expires,
|
||||||
|
addressmap_entry_source_t addrmap_source,
|
||||||
|
const char **msg);
|
||||||
|
|
||||||
|
int port_cfg_line_extract_addrport(const char *line,
|
||||||
|
char **addrport_out,
|
||||||
|
int *is_unix_out,
|
||||||
|
const char **rest_out);
|
||||||
|
|
||||||
|
/** Represents the information stored in a torrc Bridge line. */
|
||||||
|
typedef struct bridge_line_t {
|
||||||
|
tor_addr_t addr; /* The IP address of the bridge. */
|
||||||
|
uint16_t port; /* The TCP port of the bridge. */
|
||||||
|
char *transport_name; /* The name of the pluggable transport that
|
||||||
|
should be used to connect to the bridge. */
|
||||||
|
char digest[DIGEST_LEN]; /* The bridge's identity key digest. */
|
||||||
|
smartlist_t *socks_args; /* SOCKS arguments for the pluggable
|
||||||
|
transport proxy. */
|
||||||
|
} bridge_line_t;
|
||||||
|
|
||||||
|
void bridge_line_free_(bridge_line_t *bridge_line);
|
||||||
|
#define bridge_line_free(line) \
|
||||||
|
FREE_AND_NULL(bridge_line_t, bridge_line_free_, (line))
|
||||||
|
bridge_line_t *parse_bridge_line(const char *line);
|
||||||
|
|
||||||
|
/* Port helper functions. */
|
||||||
|
int options_any_client_port_set(const or_options_t *options);
|
||||||
|
int port_parse_config(smartlist_t *out,
|
||||||
|
const struct config_line_t *ports,
|
||||||
|
const char *portname,
|
||||||
|
int listener_type,
|
||||||
|
const char *defaultaddr,
|
||||||
|
int defaultport,
|
||||||
|
const unsigned flags);
|
||||||
|
|
||||||
|
#define CL_PORT_NO_STREAM_OPTIONS (1u<<0)
|
||||||
|
#define CL_PORT_WARN_NONLOCAL (1u<<1)
|
||||||
|
/* Was CL_PORT_ALLOW_EXTRA_LISTENADDR (1u<<2) */
|
||||||
|
#define CL_PORT_SERVER_OPTIONS (1u<<3)
|
||||||
|
#define CL_PORT_FORBID_NONLOCAL (1u<<4)
|
||||||
|
#define CL_PORT_TAKES_HOSTNAMES (1u<<5)
|
||||||
|
#define CL_PORT_IS_UNIXSOCKET (1u<<6)
|
||||||
|
#define CL_PORT_DFLT_GROUP_WRITABLE (1u<<7)
|
||||||
|
|
||||||
|
port_cfg_t *port_cfg_new(size_t namelen);
|
||||||
|
#define port_cfg_free(port) \
|
||||||
|
FREE_AND_NULL(port_cfg_t, port_cfg_free_, (port))
|
||||||
|
void port_cfg_free_(port_cfg_t *port);
|
||||||
|
|
||||||
|
int port_count_real_listeners(const smartlist_t *ports,
|
||||||
|
int listenertype,
|
||||||
|
int count_sockets);
|
||||||
|
int pt_parse_transport_line(const or_options_t *options,
|
||||||
|
const char *line, int validate_only,
|
||||||
|
int server);
|
||||||
|
int config_ensure_bandwidth_cap(uint64_t *value, const char *desc, char **msg);
|
||||||
|
|
||||||
|
#ifdef CONFIG_PRIVATE
|
||||||
|
|
||||||
|
MOCK_DECL(STATIC int, options_act,(const or_options_t *old_options));
|
||||||
|
MOCK_DECL(STATIC int, options_act_reversible,(const or_options_t *old_options,
|
||||||
|
char **msg));
|
||||||
|
struct config_mgr_t;
|
||||||
|
STATIC const struct config_mgr_t *get_options_mgr(void);
|
||||||
|
|
||||||
|
#define or_options_free(opt) \
|
||||||
|
FREE_AND_NULL(or_options_t, or_options_free_, (opt))
|
||||||
|
STATIC void or_options_free_(or_options_t *options);
|
||||||
|
STATIC int options_validate_single_onion(or_options_t *options,
|
||||||
|
char **msg);
|
||||||
|
STATIC int parse_tcp_proxy_line(const char *line, or_options_t *options,
|
||||||
|
char **msg);
|
||||||
|
STATIC int consider_adding_dir_servers(const or_options_t *options,
|
||||||
|
const or_options_t *old_options);
|
||||||
|
STATIC void add_default_trusted_dir_authorities(dirinfo_type_t type);
|
||||||
|
MOCK_DECL(STATIC void, add_default_fallback_dir_servers, (void));
|
||||||
|
STATIC int parse_dir_authority_line(const char *line,
|
||||||
|
dirinfo_type_t required_type,
|
||||||
|
int validate_only);
|
||||||
|
STATIC int parse_dir_fallback_line(const char *line, int validate_only);
|
||||||
|
|
||||||
|
STATIC uint64_t compute_real_max_mem_in_queues(const uint64_t val,
|
||||||
|
bool is_server);
|
||||||
|
STATIC int open_and_add_file_log(const log_severity_list_t *severity,
|
||||||
|
const char *fname,
|
||||||
|
int truncate_log);
|
||||||
|
STATIC int options_init_logs(const or_options_t *old_options,
|
||||||
|
const or_options_t *options, int validate_only);
|
||||||
|
|
||||||
|
STATIC int options_create_directories(char **msg_out);
|
||||||
|
struct log_transaction_t;
|
||||||
|
STATIC struct log_transaction_t *options_start_log_transaction(
|
||||||
|
const or_options_t *old_options,
|
||||||
|
char **msg_out);
|
||||||
|
STATIC void options_commit_log_transaction(struct log_transaction_t *xn);
|
||||||
|
STATIC void options_rollback_log_transaction(struct log_transaction_t *xn);
|
||||||
|
|
||||||
|
#ifdef TOR_UNIT_TESTS
|
||||||
|
int options_validate(const or_options_t *old_options,
|
||||||
|
or_options_t *options,
|
||||||
|
char **msg);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
STATIC int parse_ports(or_options_t *options, int validate_only,
|
||||||
|
char **msg, int *n_ports_out,
|
||||||
|
int *world_writable_control_socket);
|
||||||
|
|
||||||
|
#endif /* defined(CONFIG_PRIVATE) */
|
||||||
|
|
||||||
|
#endif /* !defined(TOR_CONFIG_H) */
|
||||||
+1107
File diff suppressed because it is too large
Load Diff
+103
@@ -0,0 +1,103 @@
|
|||||||
|
/* Copyright (c) 2001 Matej Pfajfar.
|
||||||
|
* Copyright (c) 2001-2004, Roger Dingledine.
|
||||||
|
* Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson.
|
||||||
|
* Copyright (c) 2007-2021, The Tor Project, Inc. */
|
||||||
|
/* See LICENSE for licensing information */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file or_state_st.h
|
||||||
|
*
|
||||||
|
* \brief The or_state_t structure, which represents Tor's state file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef TOR_OR_STATE_ST_H
|
||||||
|
#define TOR_OR_STATE_ST_H
|
||||||
|
|
||||||
|
#include "lib/cc/torint.h"
|
||||||
|
struct smartlist_t;
|
||||||
|
struct config_suite_t;
|
||||||
|
|
||||||
|
/** Persistent state for an onion router, as saved to disk. */
|
||||||
|
struct or_state_t {
|
||||||
|
uint32_t magic_;
|
||||||
|
/** The time at which we next plan to write the state to the disk. Equal to
|
||||||
|
* TIME_MAX if there are no saveable changes, 0 if there are changes that
|
||||||
|
* should be saved right away. */
|
||||||
|
time_t next_write;
|
||||||
|
|
||||||
|
/** When was the state last written to disk? */
|
||||||
|
time_t LastWritten;
|
||||||
|
|
||||||
|
/** Fields for accounting bandwidth use. */
|
||||||
|
time_t AccountingIntervalStart;
|
||||||
|
uint64_t AccountingBytesReadInInterval;
|
||||||
|
uint64_t AccountingBytesWrittenInInterval;
|
||||||
|
int AccountingSecondsActive;
|
||||||
|
int AccountingSecondsToReachSoftLimit;
|
||||||
|
time_t AccountingSoftLimitHitAt;
|
||||||
|
uint64_t AccountingBytesAtSoftLimit;
|
||||||
|
uint64_t AccountingExpectedUsage;
|
||||||
|
|
||||||
|
/** A list of guard-related configuration lines. */
|
||||||
|
struct config_line_t *Guard;
|
||||||
|
|
||||||
|
struct config_line_t *TransportProxies;
|
||||||
|
|
||||||
|
/** These fields hold information on the history of bandwidth usage for
|
||||||
|
* servers. The "Ends" fields hold the time when we last updated the
|
||||||
|
* bandwidth usage. The "Interval" fields hold the granularity, in seconds,
|
||||||
|
* of the entries of Values. The "Values" lists hold decimal string
|
||||||
|
* representations of the number of bytes read or written in each
|
||||||
|
* interval. The "Maxima" list holds decimal strings describing the highest
|
||||||
|
* rate achieved during the interval.
|
||||||
|
*/
|
||||||
|
time_t BWHistoryReadEnds;
|
||||||
|
int BWHistoryReadInterval;
|
||||||
|
struct smartlist_t *BWHistoryReadValues;
|
||||||
|
struct smartlist_t *BWHistoryReadMaxima;
|
||||||
|
time_t BWHistoryWriteEnds;
|
||||||
|
int BWHistoryWriteInterval;
|
||||||
|
struct smartlist_t *BWHistoryWriteValues;
|
||||||
|
struct smartlist_t *BWHistoryWriteMaxima;
|
||||||
|
time_t BWHistoryIPv6ReadEnds;
|
||||||
|
int BWHistoryIPv6ReadInterval;
|
||||||
|
struct smartlist_t *BWHistoryIPv6ReadValues;
|
||||||
|
struct smartlist_t *BWHistoryIPv6ReadMaxima;
|
||||||
|
time_t BWHistoryIPv6WriteEnds;
|
||||||
|
int BWHistoryIPv6WriteInterval;
|
||||||
|
struct smartlist_t *BWHistoryIPv6WriteValues;
|
||||||
|
struct smartlist_t *BWHistoryIPv6WriteMaxima;
|
||||||
|
time_t BWHistoryDirReadEnds;
|
||||||
|
int BWHistoryDirReadInterval;
|
||||||
|
struct smartlist_t *BWHistoryDirReadValues;
|
||||||
|
struct smartlist_t *BWHistoryDirReadMaxima;
|
||||||
|
time_t BWHistoryDirWriteEnds;
|
||||||
|
int BWHistoryDirWriteInterval;
|
||||||
|
struct smartlist_t *BWHistoryDirWriteValues;
|
||||||
|
struct smartlist_t *BWHistoryDirWriteMaxima;
|
||||||
|
|
||||||
|
/** Build time histogram */
|
||||||
|
struct config_line_t * BuildtimeHistogram;
|
||||||
|
int TotalBuildTimes;
|
||||||
|
int CircuitBuildAbandonedCount;
|
||||||
|
|
||||||
|
/** What version of Tor wrote this state file? */
|
||||||
|
char *TorVersion;
|
||||||
|
|
||||||
|
/** Holds any unrecognized values we found in the state file, in the order
|
||||||
|
* in which we found them. */
|
||||||
|
struct config_line_t *ExtraLines;
|
||||||
|
|
||||||
|
/** When did we last rotate our onion key? "0" for 'no idea'. */
|
||||||
|
time_t LastRotatedOnionKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State objects for individual modules.
|
||||||
|
*
|
||||||
|
* Never access this field or its members directly: instead, use the module
|
||||||
|
* in question to get its relevant state object if you must.
|
||||||
|
*/
|
||||||
|
struct config_suite_t *substates_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif /* !defined(TOR_OR_STATE_ST_H) */
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/* Copyright (c) 2001 Matej Pfajfar.
|
||||||
|
* Copyright (c) 2001-2004, Roger Dingledine.
|
||||||
|
* Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson.
|
||||||
|
* Copyright (c) 2007-2021, The Tor Project, Inc. */
|
||||||
|
/* See LICENSE for licensing information */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file quiet_level.h
|
||||||
|
* \brief Declare the quiet_level enumeration and global.
|
||||||
|
**/
|
||||||
|
|
||||||
|
#ifndef QUIET_LEVEL_H
|
||||||
|
#define QUIET_LEVEL_H
|
||||||
|
|
||||||
|
/** Enumeration to define how quietly Tor should log at startup. */
|
||||||
|
typedef enum {
|
||||||
|
/** Default quiet level: we log everything of level NOTICE or higher. */
|
||||||
|
QUIET_NONE = 0,
|
||||||
|
/** "--hush" quiet level: we log everything of level WARNING or higher. */
|
||||||
|
QUIET_HUSH = 1 ,
|
||||||
|
/** "--quiet" quiet level: we log nothing at all. */
|
||||||
|
QUIET_SILENT = 2
|
||||||
|
} quiet_level_t;
|
||||||
|
|
||||||
|
/** How quietly should Tor log at startup? */
|
||||||
|
extern quiet_level_t quiet_level;
|
||||||
|
|
||||||
|
void add_default_log_for_quiet_level(quiet_level_t quiet);
|
||||||
|
|
||||||
|
#endif /* !defined(QUIET_LEVEL_H) */
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/* Copyright (c) 2020-2021, The Tor Project, Inc. */
|
||||||
|
/* See LICENSE for licensing information */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file resolve_addr.h
|
||||||
|
* \brief Header file for resolve_addr.c.
|
||||||
|
**/
|
||||||
|
|
||||||
|
#ifndef TOR_CONFIG_RESOLVE_ADDR_H
|
||||||
|
#define TOR_CONFIG_RESOLVE_ADDR_H
|
||||||
|
|
||||||
|
#include "app/config/config.h"
|
||||||
|
#include "core/mainloop/connection.h"
|
||||||
|
|
||||||
|
#include "app/config/or_options_st.h"
|
||||||
|
|
||||||
|
/** Method used to resolved an address. In other words, how was the address
|
||||||
|
* discovered by tor. */
|
||||||
|
typedef enum {
|
||||||
|
/* Default value. Indicate that no method found the address. */
|
||||||
|
RESOLVED_ADDR_NONE = 0,
|
||||||
|
/* Found from the "Address" configuration option. */
|
||||||
|
RESOLVED_ADDR_CONFIGURED = 1,
|
||||||
|
/* Found from the "ORPort" configuration option. */
|
||||||
|
RESOLVED_ADDR_CONFIGURED_ORPORT = 2,
|
||||||
|
/* Found by resolving the local hostname. */
|
||||||
|
RESOLVED_ADDR_GETHOSTNAME = 3,
|
||||||
|
/* Found by querying the local interface(s). */
|
||||||
|
RESOLVED_ADDR_INTERFACE = 4,
|
||||||
|
/* Found by resolving the hostname from the Address configuration option. */
|
||||||
|
RESOLVED_ADDR_RESOLVED = 5,
|
||||||
|
} resolved_addr_method_t;
|
||||||
|
|
||||||
|
const char *resolved_addr_method_to_str(const resolved_addr_method_t method);
|
||||||
|
|
||||||
|
#define get_orport_addr(family) \
|
||||||
|
(portconf_get_first_advertised_addr(CONN_TYPE_OR_LISTENER, family))
|
||||||
|
|
||||||
|
bool find_my_address(const or_options_t *options, int family,
|
||||||
|
int warn_severity, tor_addr_t *addr_out,
|
||||||
|
resolved_addr_method_t *method_out, char **hostname_out);
|
||||||
|
|
||||||
|
void resolved_addr_get_last(int family, tor_addr_t *addr_out);
|
||||||
|
void resolved_addr_reset_last(int family);
|
||||||
|
void resolved_addr_set_last(const tor_addr_t *addr,
|
||||||
|
const resolved_addr_method_t method_used,
|
||||||
|
const char *hostname_used);
|
||||||
|
|
||||||
|
void resolved_addr_get_suggested(int family, tor_addr_t *addr_out);
|
||||||
|
void resolved_addr_set_suggested(const tor_addr_t *addr);
|
||||||
|
|
||||||
|
bool resolved_addr_is_configured(int family);
|
||||||
|
|
||||||
|
MOCK_DECL(bool, is_local_to_resolve_addr, (const tor_addr_t *addr));
|
||||||
|
|
||||||
|
#ifdef RESOLVE_ADDR_PRIVATE
|
||||||
|
|
||||||
|
#ifdef TOR_UNIT_TESTS
|
||||||
|
|
||||||
|
void resolve_addr_reset_suggested(int family);
|
||||||
|
|
||||||
|
#endif /* TOR_UNIT_TESTS */
|
||||||
|
|
||||||
|
#endif /* defined(RESOLVE_ADDR_PRIVATE) */
|
||||||
|
|
||||||
|
#endif /* !defined(TOR_CONFIG_RESOLVE_ADDR_H) */
|
||||||
|
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/* Copyright (c) 2001 Matej Pfajfar.
|
||||||
|
* Copyright (c) 2001-2004, Roger Dingledine.
|
||||||
|
* Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson.
|
||||||
|
* Copyright (c) 2007-2021, The Tor Project, Inc. */
|
||||||
|
/* See LICENSE for licensing information */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file statefile.h
|
||||||
|
*
|
||||||
|
* \brief Header for statefile.c
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef TOR_STATEFILE_H
|
||||||
|
#define TOR_STATEFILE_H
|
||||||
|
|
||||||
|
MOCK_DECL(or_state_t *,get_or_state,(void));
|
||||||
|
int did_last_state_file_write_fail(void);
|
||||||
|
int or_state_save(time_t now);
|
||||||
|
|
||||||
|
void save_transport_to_state(const char *transport_name,
|
||||||
|
const tor_addr_t *addr, uint16_t port);
|
||||||
|
char *get_stored_bindaddr_for_server_transport(const char *transport);
|
||||||
|
int or_state_load(void);
|
||||||
|
int or_state_loaded(void);
|
||||||
|
void or_state_free_all(void);
|
||||||
|
void or_state_mark_dirty(or_state_t *state, time_t when);
|
||||||
|
|
||||||
|
#ifdef STATEFILE_PRIVATE
|
||||||
|
STATIC struct config_line_t *get_transport_in_state_by_name(
|
||||||
|
const char *transport);
|
||||||
|
STATIC void or_state_free_(or_state_t *state);
|
||||||
|
#define or_state_free(st) FREE_AND_NULL(or_state_t, or_state_free_, (st))
|
||||||
|
STATIC or_state_t *or_state_new(void);
|
||||||
|
struct config_mgr_t;
|
||||||
|
STATIC const struct config_mgr_t *get_state_mgr(void);
|
||||||
|
STATIC void or_state_remove_obsolete_lines(struct config_line_t **extra_lines);
|
||||||
|
#endif /* defined(STATEFILE_PRIVATE) */
|
||||||
|
|
||||||
|
#endif /* !defined(TOR_STATEFILE_H) */
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user