mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
265 lines
11 KiB
Swift
265 lines
11 KiB
Swift
// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import SessionUtilitiesKit
|
|
|
|
public enum BuildPathsJob: JobExecutor {
|
|
public static let maxFailureCount: Int = 0
|
|
public static let requiresThreadId: Bool = false
|
|
public static let requiresInteractionId: Bool = false
|
|
|
|
/// The number of paths to maintain.
|
|
public static let targetPathCount: UInt = 2
|
|
|
|
/// The number of guard snodes required to maintain `targetPathCount` paths.
|
|
private static var targetGuardSnodeCount: Int { return Int(targetPathCount) } // One per path
|
|
|
|
public static func run(
|
|
_ job: Job,
|
|
queue: DispatchQueue,
|
|
success: @escaping (Job, Bool, Dependencies) -> (),
|
|
failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
|
|
deferred: @escaping (Job, Dependencies) -> (),
|
|
using dependencies: Dependencies
|
|
) {
|
|
guard
|
|
let detailsData: Data = job.details,
|
|
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData),
|
|
let ed25519SecretKey: [UInt8] = details.ed25519SecretKey
|
|
else {
|
|
SNLog("[BuildPathsJob] Failing due to missing details.")
|
|
return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies)
|
|
}
|
|
|
|
SNLog("[BuildPathsJob] Starting.")
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(name: .buildingPaths, object: nil)
|
|
}
|
|
|
|
/// First we need to get the guard snodes
|
|
getGuardSnodes(
|
|
reusableGuardSnodes: details.reusablePaths.map { $0[0] },
|
|
ed25519SecretKey: ed25519SecretKey,
|
|
queue: queue,
|
|
using: dependencies
|
|
)
|
|
.tryMap { (guardSnodes: Set<Snode>) -> [[Snode]] in
|
|
var unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue
|
|
.subtracting(guardSnodes)
|
|
.subtracting(details.reusablePaths.flatMap { $0 })
|
|
let pathSnodeCount: Int = (targetGuardSnodeCount - details.reusablePaths.count) * OnionRequestAPI.pathSize - (targetGuardSnodeCount - details.reusablePaths.count)
|
|
|
|
guard unusedSnodes.count >= pathSnodeCount else {
|
|
throw SnodeAPIError.insufficientSnodes
|
|
}
|
|
|
|
/// Don't test path snodes as this would reveal the user's IP to them
|
|
return guardSnodes
|
|
.subtracting(details.reusablePaths.compactMap { $0.first })
|
|
.map { (guardSnode: Snode) -> [Snode] in
|
|
let additionalSnodes: [Snode] = (0..<(OnionRequestAPI.pathSize - 1)).map { _ in
|
|
/// randomElement() uses the system's default random generator, which is cryptographically secure, the
|
|
/// force-unwrap here is safe because of the `pathSnodeCount` check above
|
|
unusedSnodes.popRandomElement()!
|
|
}
|
|
let result: [Snode] = [guardSnode].appending(contentsOf: additionalSnodes)
|
|
SNLog("[BuildPathsJob] Built new onion request path: \(result.prettifiedDescription).")
|
|
return result
|
|
}
|
|
}
|
|
.subscribe(on: queue, using: dependencies)
|
|
.receive(on: queue, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure(let error):
|
|
SNLog("[BuildPathsJob] Failed due to error: \(error)")
|
|
failure(job, error, false, dependencies)
|
|
}
|
|
},
|
|
receiveValue: { (output: [[Snode]]) in
|
|
OnionRequestAPI.paths = (output + details.reusablePaths)
|
|
|
|
dependencies.storage.write(using: dependencies) { db in
|
|
SNLog("[BuildPathsJob] Persisting onion request paths to database.")
|
|
try? output.save(db)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
|
|
}
|
|
|
|
SNLog("[BuildPathsJob] Complete.")
|
|
success(job, false, dependencies)
|
|
}
|
|
)
|
|
}
|
|
|
|
private static func getGuardSnodes(
|
|
reusableGuardSnodes: [Snode],
|
|
ed25519SecretKey: [UInt8],
|
|
queue: DispatchQueue,
|
|
using dependencies: Dependencies
|
|
) -> AnyPublisher<Set<Snode>, Error> {
|
|
guard OnionRequestAPI.guardSnodes.wrappedValue.count < targetGuardSnodeCount else {
|
|
return Just(OnionRequestAPI.guardSnodes.wrappedValue)
|
|
.setFailureType(to: Error.self)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return Deferred {
|
|
Future<(unusedSnodes: Set<Snode>, requiredGuardNodes: Int), Error> { resolver in
|
|
SNLog("[BuildPathsJob] Populating guard snode cache.")
|
|
let unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue.subtracting(reusableGuardSnodes)
|
|
let requiredGuardNodes: Int = (targetGuardSnodeCount - reusableGuardSnodes.count)
|
|
|
|
guard unusedSnodes.count >= requiredGuardNodes else {
|
|
return resolver(Result.failure(SnodeAPIError.insufficientSnodes))
|
|
}
|
|
|
|
resolver(Result.success((unusedSnodes, requiredGuardNodes)))
|
|
}
|
|
}
|
|
.flatMap { originalUnusedSnodes, requiredGuardNodes -> AnyPublisher<Set<Snode>, Error> in
|
|
var unusedSnodes: Set<Snode> = originalUnusedSnodes
|
|
|
|
func getGuardSnode() -> AnyPublisher<Snode, Error> {
|
|
// randomElement() uses the system's default random generator, which
|
|
// is cryptographically secure
|
|
guard let candidate = unusedSnodes.randomElement() else {
|
|
return Fail(error: SnodeAPIError.insufficientSnodes)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
unusedSnodes.remove(candidate) // All used snodes should be unique
|
|
SNLog("[BuildPathsJob] Testing guard snode: \(candidate).")
|
|
|
|
// Loop until a reliable guard snode is found
|
|
return SnodeAPI
|
|
.testSnode(
|
|
snode: candidate,
|
|
ed25519SecretKey: ed25519SecretKey,
|
|
using: dependencies
|
|
)
|
|
.map { _ in candidate }
|
|
.catch { _ in
|
|
return Just(())
|
|
.setFailureType(to: Error.self)
|
|
.delay(for: .milliseconds(100), scheduler: queue)
|
|
.flatMap { _ in getGuardSnode() }
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return Publishers
|
|
.MergeMany((0..<requiredGuardNodes).map { _ in getGuardSnode() })
|
|
.collect()
|
|
.map { output in Set(output) }
|
|
.handleEvents(
|
|
receiveOutput: { output in
|
|
OnionRequestAPI.guardSnodes.mutate { $0 = output }
|
|
}
|
|
)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
public static func runIfNeeded(
|
|
excluding snodeToExclude: Snode? = nil,
|
|
ed25519SecretKey: [UInt8]?,
|
|
using dependencies: Dependencies
|
|
) -> AnyPublisher<Void, Error> {
|
|
let paths: [[Snode]] = OnionRequestAPI.paths
|
|
|
|
// Ensure the `guardSnodes` is up to date
|
|
if !paths.isEmpty {
|
|
OnionRequestAPI.guardSnodes.mutate {
|
|
$0.formUnion([ paths[0][0] ])
|
|
|
|
if paths.count >= 2 {
|
|
$0.formUnion([ paths[1][0] ])
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we have enough paths then no need to do anything
|
|
guard paths.count < targetPathCount else {
|
|
return Just(())
|
|
.setFailureType(to: Error.self)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return Deferred {
|
|
Future<Void, Error> { resolver in
|
|
let hasValidPath: Bool = snodeToExclude
|
|
.map { snode in paths.contains { !$0.contains(snode) } }
|
|
.defaulting(to: !paths.isEmpty)
|
|
|
|
let targetJob: Job? = dependencies.storage.write(using: dependencies) { db in
|
|
return dependencies.jobRunner.upsert(
|
|
db,
|
|
job: Job(
|
|
variant: .buildPaths,
|
|
behaviour: .runOnceTransient,
|
|
shouldBeUnique: true,
|
|
details: Details(reusablePaths: paths, ed25519SecretKey: ed25519SecretKey)
|
|
),
|
|
canStartJob: true,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
guard let job: Job = targetJob else {
|
|
SNLog("[BuildPathsJob] Failed to retrieve existing job or schedule a new one.")
|
|
return resolver(Result.failure(JobRunnerError.generic))
|
|
}
|
|
|
|
// If we don't have a valid path then we should block this request until we have rebuilt
|
|
// the paths
|
|
guard hasValidPath else {
|
|
dependencies.jobRunner.afterJob(job) { result in
|
|
switch result {
|
|
case .succeeded: resolver(Result.success(()))
|
|
case .failed(let error, _): resolver(Result.failure(error ?? JobRunnerError.generic))
|
|
case .deferred, .notFound: resolver(Result.failure(JobRunnerError.generic))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Otherwise we can let the `BuildPathsJob` run in the background and should just return
|
|
// immediately
|
|
SNLog("[BuildPathsJob] Scheduled in background due to existing valid path.")
|
|
resolver(Result.success(()))
|
|
}
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
// MARK: - BuildPathsJob.Details
|
|
|
|
extension BuildPathsJob {
|
|
public struct Details: Codable, UniqueHashable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case reusablePaths
|
|
case ed25519SecretKey
|
|
}
|
|
|
|
fileprivate let reusablePaths: [[Snode]]
|
|
fileprivate let ed25519SecretKey: [UInt8]?
|
|
|
|
// MARK: - UniqueHashable
|
|
|
|
/// We want the `BuildPathsJob` to be unique regardless of what data is given to it
|
|
public var customHash: Int {
|
|
var hasher: Hasher = Hasher()
|
|
"BuildPathsJob.Details".hash(into: &hasher)
|
|
return hasher.finalize()
|
|
}
|
|
}
|
|
}
|