mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
723 lines
32 KiB
Swift
723 lines
32 KiB
Swift
import Foundation
|
|
import Security
|
|
import LocalAuthentication
|
|
import Darwin
|
|
import SwiftSignalKit
|
|
|
|
public struct PasscodeConfiguration: Equatable, Sendable {
|
|
public enum ProcessRole: Equatable, Sendable {
|
|
case mainApp
|
|
case appExtension
|
|
}
|
|
|
|
public let appGroupIdentifier: String
|
|
public let processRole: ProcessRole
|
|
public let biometricKeychainService: String?
|
|
public let keychainScope: String?
|
|
|
|
public init(appGroupIdentifier: String, processRole: ProcessRole, biometricKeychainService: String? = nil, keychainScope: String? = nil) {
|
|
self.appGroupIdentifier = appGroupIdentifier
|
|
self.processRole = processRole
|
|
self.biometricKeychainService = biometricKeychainService
|
|
self.keychainScope = keychainScope
|
|
}
|
|
}
|
|
|
|
public final class PasscodeEnvironment: @unchecked Sendable {
|
|
public static let shared = PasscodeEnvironment()
|
|
|
|
private let mutex = NSLock()
|
|
private var configuration: PasscodeConfiguration?
|
|
private var resolvePrivateAccessGroup: (() -> String?)?
|
|
private var resolvedPrivateAccessGroup: String?
|
|
|
|
init() {
|
|
|
|
}
|
|
|
|
public func configure(_ configuration: PasscodeConfiguration, privateAccessGroup: (() -> String?)? = nil) throws {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
#if os(macOS)
|
|
guard !configuration.appGroupIdentifier.isEmpty, configuration.appGroupIdentifier.contains(".") else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
#else
|
|
guard configuration.appGroupIdentifier.hasPrefix("group."), configuration.appGroupIdentifier.count > "group.".count else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
#endif
|
|
if let current = self.configuration {
|
|
guard current == configuration else { throw PasscodeError.unavailable }
|
|
return
|
|
}
|
|
guard configuration.processRole != .mainApp || privateAccessGroup != nil else { throw PasscodeError.unavailable }
|
|
self.configuration = configuration
|
|
self.resolvePrivateAccessGroup = privateAccessGroup
|
|
}
|
|
|
|
public var keychainScope: String? {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
guard let scope = self.configuration?.keychainScope, !scope.isEmpty else { return nil }
|
|
return scope
|
|
}
|
|
|
|
public var isMainApp: Bool {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
return self.configuration?.processRole == .mainApp
|
|
}
|
|
|
|
public func sharedAccessGroup() throws -> String {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
guard let configuration = self.configuration else { throw PasscodeError.unavailable }
|
|
return configuration.appGroupIdentifier
|
|
}
|
|
|
|
func biometricKeychainService() throws -> String {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
guard let configuration = self.configuration, configuration.processRole == .mainApp,
|
|
let service = configuration.biometricKeychainService, !service.isEmpty else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
return service
|
|
}
|
|
|
|
public func privateAccessGroup() throws -> String {
|
|
self.mutex.lock()
|
|
defer { self.mutex.unlock() }
|
|
guard let configuration = self.configuration, configuration.processRole == .mainApp else { throw PasscodeError.unavailable }
|
|
if let group = self.resolvedPrivateAccessGroup { return group }
|
|
guard let group = self.resolvePrivateAccessGroup?(), !group.isEmpty else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
#if !os(macOS)
|
|
guard !group.hasPrefix("group."),
|
|
group.hasSuffix("." + configuration.appGroupIdentifier.dropFirst("group.".count)) else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
#endif
|
|
self.resolvedPrivateAccessGroup = group
|
|
return group
|
|
}
|
|
}
|
|
|
|
protocol PasscodeStorage {
|
|
func read(_ account: String, context: LAContext?) throws -> Data?
|
|
func write(_ data: Data, account: String, biometric: Bool) throws
|
|
func remove(_ account: String) throws
|
|
}
|
|
|
|
@available(macOS 10.15, *)
|
|
final class PasscodeKeychain: PasscodeStorage {
|
|
private let environment: PasscodeEnvironment
|
|
init(environment: PasscodeEnvironment = .shared) { self.environment = environment }
|
|
|
|
func query(_ account: String) throws -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: try self.service(account),
|
|
kSecAttrAccount as String: account,
|
|
kSecAttrSynchronizable as String: false
|
|
]
|
|
#if !os(macOS)
|
|
query[kSecAttrAccessGroup as String] = try account.hasPrefix("biometric.") ? self.environment.privateAccessGroup() : self.environment.sharedAccessGroup()
|
|
#endif
|
|
return query
|
|
}
|
|
|
|
private func service(_ account: String) throws -> String {
|
|
let service = try account.hasPrefix("biometric.") ? self.environment.biometricKeychainService() : "org.telegram.passcode.v1"
|
|
#if os(macOS)
|
|
return PasscodeKeychainScope.service(service, environment: self.environment)
|
|
#else
|
|
return service
|
|
#endif
|
|
}
|
|
|
|
func read(_ account: String, context: LAContext? = nil) throws -> Data? {
|
|
if let value = try self.rawRead(account, context: context) {
|
|
return value
|
|
}
|
|
#if os(macOS)
|
|
if !account.hasPrefix("biometric."), self.environment.keychainScope == nil {
|
|
return self.adoptGroupedItem(account)
|
|
}
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
private func rawRead(_ account: String, context: LAContext?) throws -> Data? {
|
|
var query = try self.query(account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
if let context { query[kSecUseAuthenticationContext as String] = context }
|
|
var result: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
if status == errSecItemNotFound { return nil }
|
|
if status == errSecUserCanceled { throw PasscodeError.cancelled }
|
|
guard status == errSecSuccess, let data = result as? Data else { throw PasscodeError.keychain(status) }
|
|
return data
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func adoptGroupedItem(_ account: String) -> Data? {
|
|
guard let group = try? self.environment.sharedAccessGroup(),
|
|
var query = try? self.query(account) else {
|
|
return nil
|
|
}
|
|
query[kSecAttrAccessGroup as String] = group
|
|
query[kSecUseDataProtectionKeychain as String] = true
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
let data = result as? Data else {
|
|
return nil
|
|
}
|
|
try? self.write(data, account: account)
|
|
return data
|
|
}
|
|
#endif
|
|
|
|
func write(_ data: Data, account: String, biometric: Bool = false) throws {
|
|
let query = try self.query(account)
|
|
if !biometric {
|
|
let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
if status == errSecSuccess { return }
|
|
guard status == errSecItemNotFound else { throw PasscodeError.keychain(status) }
|
|
}
|
|
var insert = query
|
|
insert[kSecValueData as String] = data
|
|
if biometric {
|
|
var error: Unmanaged<CFError>?
|
|
guard let acl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, &error) else {
|
|
_ = error?.takeRetainedValue()
|
|
throw PasscodeError.biometricsUnavailable
|
|
}
|
|
insert[kSecAttrAccessControl as String] = acl
|
|
} else {
|
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
}
|
|
let status = SecItemAdd(insert as CFDictionary, nil)
|
|
if biometric, status == errSecMissingEntitlement {
|
|
throw PasscodeError.biometricsUnavailable
|
|
}
|
|
guard status == errSecSuccess else { throw PasscodeError.keychain(status) }
|
|
}
|
|
|
|
func remove(_ account: String) throws {
|
|
let status = SecItemDelete(try self.query(account) as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else { throw PasscodeError.keychain(status) }
|
|
}
|
|
}
|
|
|
|
public enum PasscodeKind: String, Codable, Sendable {
|
|
case digits4, digits6, alphanumeric
|
|
|
|
public func normalize(_ code: String) -> String {
|
|
guard self != .alphanumeric else { return code.precomposedStringWithCanonicalMapping }
|
|
return String(code.map { character in
|
|
if let value = character.wholeNumberValue, value < 10 { return Character(String(value)) }
|
|
return character
|
|
})
|
|
}
|
|
}
|
|
|
|
public struct PasscodeCredentialReference: Codable, Equatable, Sendable {
|
|
public let id: String
|
|
public let kind: PasscodeKind
|
|
|
|
public init(id: String, kind: PasscodeKind) {
|
|
self.id = id
|
|
self.kind = kind
|
|
}
|
|
}
|
|
|
|
public struct PasscodeProtectionSettings: Equatable, Sendable {
|
|
public let passcode: PasscodeCredentialReference?
|
|
public let enabled: Bool
|
|
public let biometricsEnabled: Bool
|
|
}
|
|
|
|
@available(macOS 10.15, *)
|
|
public final class PasscodeCredentialStore: @unchecked Sendable {
|
|
public static let shared = PasscodeCredentialStore(storage: PasscodeKeychain())
|
|
|
|
public var changes: Signal<Void, NoError> {
|
|
return self.changesPipe.signal()
|
|
}
|
|
|
|
struct Record: Codable {
|
|
var version = 1
|
|
var id: String
|
|
var revision: UInt64 = 1
|
|
var managedPasscode = false
|
|
var kind: PasscodeKind?
|
|
var salt: Data?
|
|
var iterations: Int?
|
|
var wrappedKey: Data?
|
|
var unprotectedKey: Data?
|
|
var protectionEnabled = false
|
|
var biometricAccount: String?
|
|
var pendingBiometricAccount: String?
|
|
var biometricCleanup: [String] = []
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case version, id, revision, managedPasscode, kind, salt, iterations
|
|
case wrappedKey, unprotectedKey, biometricAccount, pendingBiometricAccount, biometricCleanup
|
|
case protectionEnabled = "walletProtection"
|
|
}
|
|
}
|
|
|
|
private struct Attempts: Codable {
|
|
var count = 0
|
|
var boot: Int64
|
|
var deadline: TimeInterval = 0
|
|
}
|
|
|
|
private let storage: PasscodeStorage
|
|
private let changesPipe = ValuePipe<Void>()
|
|
private let mutex = NSRecursiveLock()
|
|
private var lockPath: String?
|
|
private var expectsCredential = false
|
|
private let iterationCount: () -> Int
|
|
private let clock: () -> (boot: Int64, uptime: TimeInterval)
|
|
|
|
init(storage: PasscodeStorage, iterations: @escaping () -> Int = PasscodeCrypto.iterations,
|
|
clock: @escaping () -> (Int64, TimeInterval) = {
|
|
var time = timeval()
|
|
var size = MemoryLayout<timeval>.size
|
|
guard sysctlbyname("kern.boottime", &time, &size, nil, 0) == 0 else { return (0, ProcessInfo.processInfo.systemUptime) }
|
|
return (Int64(time.tv_sec), ProcessInfo.processInfo.systemUptime)
|
|
}) {
|
|
self.storage = storage
|
|
self.iterationCount = iterations
|
|
self.clock = clock
|
|
}
|
|
|
|
/// The AccountManager directory is shared with extensions. All credential and
|
|
/// attempt changes hold this cross-process lock, including read-modify-write.
|
|
public func configureLock(directory: String) {
|
|
self.mutex.lock()
|
|
self.lockPath = directory + "/passcode-v1.lock"
|
|
self.mutex.unlock()
|
|
}
|
|
|
|
/// Whether a credential record is present. Answering this must not depend on
|
|
/// `requireExistingCredential`, since the caller asks precisely to decide
|
|
/// whether requiring one can be honoured.
|
|
public func hasStoredCredential() throws -> Bool {
|
|
try self.serialized {
|
|
guard let data = try self.storage.read("credential", context: nil) else { return false }
|
|
return (try? JSONDecoder().decode(Record.self, from: data)) != nil
|
|
}
|
|
}
|
|
|
|
public func requireExistingCredential() {
|
|
self.mutex.lock()
|
|
self.expectsCredential = true
|
|
self.mutex.unlock()
|
|
}
|
|
|
|
/// Destructive credential reset. The owner must remove all protected data
|
|
/// before the access key is replaced. Cleanup runs under the credential lock;
|
|
/// it must not reenter this store. A cleanup failure leaves persisted keys intact.
|
|
public func resetCredential(removingProtectedData: () throws -> Void) throws {
|
|
try self.serialized {
|
|
PasscodeSession.invalidateAll()
|
|
try removingProtectedData()
|
|
try self.storage.remove("attempts")
|
|
try self.storage.write(PasscodeCrypto.random(32), account: "device", biometric: false)
|
|
let record = Record(id: UUID().uuidString, managedPasscode: true, unprotectedKey: try PasscodeCrypto.random(32))
|
|
try self.save(record)
|
|
self.expectsCredential = false
|
|
}
|
|
}
|
|
|
|
/// Removes credential authority so the next launch can migrate legacy metadata.
|
|
/// Both closures run under the credential lock and must not reenter this store.
|
|
/// The continuation must commit the legacy state and terminate the process.
|
|
public func _internalResetForPasscodeMigrationTest(removingProtectedData: () throws -> Void, then: () throws -> Never) throws -> Never {
|
|
try self.serialized { () throws -> Never in
|
|
PasscodeSession.invalidateAll()
|
|
try removingProtectedData()
|
|
try self.storage.remove("attempts")
|
|
try self.storage.remove("device")
|
|
try self.storage.remove("credential")
|
|
self.expectsCredential = false
|
|
try then()
|
|
}
|
|
}
|
|
|
|
private func serialized<T>(_ body: () throws -> T) throws -> T {
|
|
self.mutex.lock()
|
|
defer {
|
|
self.mutex.unlock()
|
|
}
|
|
var fd: Int32 = -1
|
|
if let lockPath {
|
|
fd = open(lockPath, O_CREAT | O_RDWR | O_CLOEXEC, S_IRUSR | S_IWUSR)
|
|
guard fd >= 0 else {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
guard flock(fd, LOCK_EX) == 0 else {
|
|
close(fd)
|
|
throw PasscodeError.unavailable
|
|
}
|
|
}
|
|
defer {
|
|
if fd >= 0 { flock(fd, LOCK_UN); close(fd) }
|
|
}
|
|
return try body()
|
|
}
|
|
|
|
private func load() throws -> Record? {
|
|
guard let data = try self.storage.read("credential", context: nil) else {
|
|
if self.expectsCredential {
|
|
throw PasscodeError.unavailable
|
|
}
|
|
return nil
|
|
}
|
|
guard let value = try? JSONDecoder().decode(Record.self, from: data), value.version == 1, !value.id.isEmpty,
|
|
value.revision > 0,
|
|
value.kind == nil || (value.salt?.count == 16 && value.iterations != nil && value.wrappedKey?.count == 60),
|
|
!value.protectionEnabled || (value.kind != nil && value.unprotectedKey == nil),
|
|
value.protectionEnabled || value.unprotectedKey?.count == 32 else {
|
|
throw PasscodeError.corrupted
|
|
}
|
|
return value
|
|
}
|
|
|
|
private func save(_ record: Record, notify: Bool = true) throws {
|
|
try self.storage.write(JSONEncoder().encode(record), account: "credential", biometric: false)
|
|
if notify {
|
|
Queue.mainQueue().async {
|
|
self.changesPipe.putNext(())
|
|
}
|
|
}
|
|
}
|
|
|
|
private func existingOrCreate() throws -> Record {
|
|
if let record = try self.load() { return record }
|
|
if try self.storage.read("device", context: nil) == nil {
|
|
try self.storage.write(PasscodeCrypto.random(32), account: "device", biometric: false)
|
|
}
|
|
let record = Record(id: UUID().uuidString, unprotectedKey: try PasscodeCrypto.random(32))
|
|
try self.save(record, notify: false)
|
|
return record
|
|
}
|
|
|
|
private func deviceSecret() throws -> Data {
|
|
guard let value = try self.storage.read("device", context: nil), value.count == 32 else { throw PasscodeError.unavailable }
|
|
return value
|
|
}
|
|
|
|
private func reference(_ record: Record) -> PasscodeCredentialReference? {
|
|
record.kind.map { PasscodeCredentialReference(id: record.id, kind: $0) }
|
|
}
|
|
|
|
public func protectionSettings() throws -> PasscodeProtectionSettings {
|
|
try self.serialized {
|
|
guard let record = try self.load() else { return PasscodeProtectionSettings(passcode: nil, enabled: false, biometricsEnabled: false) }
|
|
return PasscodeProtectionSettings(passcode: self.reference(record), enabled: record.protectionEnabled, biometricsEnabled: record.biometricAccount != nil)
|
|
}
|
|
}
|
|
|
|
public func managedReference() throws -> PasscodeCredentialReference?? {
|
|
try self.serialized {
|
|
guard let record = try self.load(), record.managedPasscode else { return nil }
|
|
return .some(self.reference(record))
|
|
}
|
|
}
|
|
|
|
public func migrateLegacy(code: String, kind: PasscodeKind) throws -> PasscodeCredentialReference? {
|
|
try self.serialized {
|
|
var record = try self.existingOrCreate()
|
|
if record.managedPasscode { return self.reference(record) }
|
|
guard let key = record.unprotectedKey else { throw PasscodeError.corrupted }
|
|
try self.wrap(code: code, kind: kind, key: key, record: &record)
|
|
record.managedPasscode = true
|
|
record.revision &+= 1
|
|
try self.save(record)
|
|
PasscodeSession.invalidateAll()
|
|
return self.reference(record)
|
|
}
|
|
}
|
|
|
|
private func wrap(code: String, kind: PasscodeKind, key: Data, record: inout Record) throws {
|
|
let code = kind.normalize(code)
|
|
guard !code.isEmpty,
|
|
kind == .alphanumeric || (code.allSatisfy { $0.isASCII && $0.isNumber } && code.count == (kind == .digits6 ? 6 : 4)) else { throw PasscodeError.invalidCode }
|
|
let salt = try PasscodeCrypto.random(16)
|
|
let iterations = self.iterationCount()
|
|
var kek = try PasscodeCrypto.derive(code: code, deviceSecret: self.deviceSecret(), salt: salt, iterations: iterations)
|
|
defer { kek.resetBytes(in: 0 ..< kek.count) }
|
|
let wrapped = try PasscodeCrypto.seal(key, key: kek, context: "credential.v1:\(record.id):\(kind.rawValue)")
|
|
guard try PasscodeCrypto.open(wrapped, key: kek, context: "credential.v1:\(record.id):\(kind.rawValue)") == key else { throw PasscodeError.corrupted }
|
|
record.kind = kind
|
|
record.salt = salt
|
|
record.iterations = iterations
|
|
record.wrappedKey = wrapped
|
|
}
|
|
|
|
private func attempts() throws -> Attempts {
|
|
let time = self.clock()
|
|
guard let data = try self.storage.read("attempts", context: nil) else { return Attempts(boot: time.boot) }
|
|
guard var value = try? JSONDecoder().decode(Attempts.self, from: data), (0 ... 1_000_000).contains(value.count),
|
|
value.deadline.isFinite, value.deadline >= 0 else { throw PasscodeError.corrupted }
|
|
if value.boot == time.boot, value.deadline > time.uptime + 60 { throw PasscodeError.corrupted }
|
|
if value.boot != time.boot {
|
|
value.boot = time.boot
|
|
value.deadline = value.count >= 6 ? time.uptime + 60 : 0
|
|
try self.storage.write(JSONEncoder().encode(value), account: "attempts", biometric: false)
|
|
}
|
|
return value
|
|
}
|
|
|
|
public func cooldownRemaining() throws -> Int {
|
|
try self.serialized { Int(max(0, min(60, ceil(try self.attempts().deadline - self.clock().uptime)))) }
|
|
}
|
|
|
|
public func verify(_ code: String, reference: PasscodeCredentialReference? = nil, scope: PasscodeSession.Scope, lifetime: PasscodeSession.Lifetime = .standard) throws -> PasscodeSession {
|
|
if case let .resource(namespace) = scope, namespace.isEmpty { throw PasscodeError.authenticationRequired }
|
|
if lifetime == .ownerManaged {
|
|
guard case .resource = scope else { throw PasscodeError.authenticationRequired }
|
|
}
|
|
return try self.serialized {
|
|
guard let record = try self.load(), let kind = record.kind, let salt = record.salt,
|
|
let iterations = record.iterations, let wrapped = record.wrappedKey else { throw PasscodeError.authenticationRequired }
|
|
if let reference, reference.id != record.id { throw PasscodeError.staleAuthorization }
|
|
var attempts = try self.attempts()
|
|
let remaining = Int(max(0, min(60, ceil(attempts.deadline - self.clock().uptime))))
|
|
guard remaining == 0 else { throw PasscodeError.cooldown(remaining) }
|
|
var kek = try PasscodeCrypto.derive(code: kind.normalize(code), deviceSecret: self.deviceSecret(), salt: salt, iterations: iterations)
|
|
defer { kek.resetBytes(in: 0 ..< kek.count) }
|
|
let key: Data
|
|
do { key = try PasscodeCrypto.open(wrapped, key: kek, context: "credential.v1:\(record.id):\(kind.rawValue)") }
|
|
catch {
|
|
attempts.count = min(1_000_000, attempts.count + 1)
|
|
attempts.deadline = attempts.count >= 6 ? self.clock().uptime + 60 : 0
|
|
try self.storage.write(JSONEncoder().encode(attempts), account: "attempts", biometric: false)
|
|
throw PasscodeError.invalidCode
|
|
}
|
|
try self.storage.remove("attempts")
|
|
return PasscodeSession(credentialId: record.id, revision: record.revision, key: key, scope: scope, authentication: .passcode, lifetime: lifetime)
|
|
}
|
|
}
|
|
|
|
public func validate(_ session: PasscodeSession, scope: PasscodeSession.Scope? = nil, requireAvailable: Bool = true) throws {
|
|
try self.serialized {
|
|
if let scope { try session.require(scope: scope) }
|
|
_ = try self.validatedRecord(session, requireAvailable: requireAvailable)
|
|
}
|
|
}
|
|
|
|
private func commit(_ record: Record, retaining session: PasscodeSession) throws {
|
|
try self.save(record)
|
|
PasscodeSession.invalidateAll(except: session)
|
|
try session.advance(to: record.revision)
|
|
}
|
|
|
|
private func validatedRecord(_ session: PasscodeSession, requireAvailable: Bool = true) throws -> Record {
|
|
guard let record = try self.load(), record.id == session.credentialId, record.revision == session.revision, session.isValid else { throw PasscodeError.staleAuthorization }
|
|
if requireAvailable { try session.withKey { _ in } }
|
|
return record
|
|
}
|
|
|
|
public func unprotectedSession(namespace: String, lifetime: PasscodeSession.Lifetime = .standard) throws -> PasscodeSession {
|
|
guard !namespace.isEmpty else { throw PasscodeError.authenticationRequired }
|
|
return try self.serialized {
|
|
let record = try self.existingOrCreate()
|
|
guard !record.protectionEnabled, let key = record.unprotectedKey else { throw PasscodeError.authenticationRequired }
|
|
return PasscodeSession(credentialId: record.id, revision: record.revision, key: key, scope: .resource(namespace: namespace), lifetime: lifetime)
|
|
}
|
|
}
|
|
|
|
public func setPasscode(_ code: String, kind: PasscodeKind, session: PasscodeSession?) throws -> PasscodeCredentialReference {
|
|
try self.serialized { try self.updatePasscode(code, kind: kind, access: session, createSession: false).reference }
|
|
}
|
|
|
|
public func setPasscodeWithSettingsSession(_ code: String, kind: PasscodeKind, session: PasscodeSession?) throws -> (reference: PasscodeCredentialReference, session: PasscodeSession) {
|
|
try self.serialized {
|
|
let result = try self.updatePasscode(code, kind: kind, access: session, createSession: true)
|
|
guard let session = result.session else { throw PasscodeError.unavailable }
|
|
return (result.reference, session)
|
|
}
|
|
}
|
|
|
|
private func requirePasscodeManagement(_ session: PasscodeSession) throws {
|
|
switch session.scope {
|
|
case .managePasscode, .settings:
|
|
try session.require(scope: session.scope, passcode: true)
|
|
default:
|
|
throw PasscodeError.authenticationRequired
|
|
}
|
|
}
|
|
|
|
private func updatePasscode(_ code: String, kind: PasscodeKind, access: PasscodeSession?, createSession: Bool) throws -> (reference: PasscodeCredentialReference, session: PasscodeSession?) {
|
|
guard !Task.isCancelled else { throw PasscodeError.cancelled }
|
|
if let access { try self.requirePasscodeManagement(access) }
|
|
var record = try self.existingOrCreate()
|
|
let isInitialSetup = record.kind == nil
|
|
var key: Data
|
|
if record.kind != nil {
|
|
guard let access else { throw PasscodeError.authenticationRequired }
|
|
record = try self.validatedRecord(access)
|
|
key = try access.withKey { $0 }
|
|
} else {
|
|
guard let value = record.unprotectedKey else { throw PasscodeError.corrupted }
|
|
key = value
|
|
}
|
|
defer { key.resetBytes(in: 0 ..< key.count) }
|
|
// Register before wrapping so a concurrent global revocation reaches the new session.
|
|
let session = createSession ? PasscodeSession(credentialId: record.id, revision: record.revision &+ 1, key: key, scope: .settings, authentication: .passcode) : nil
|
|
var committed = false
|
|
defer { if !committed { session?.invalidate() } }
|
|
try self.wrap(code: code, kind: kind, key: key, record: &record)
|
|
if isInitialSetup {
|
|
record.protectionEnabled = true
|
|
record.unprotectedKey = nil
|
|
}
|
|
record.managedPasscode = true
|
|
record.revision &+= 1
|
|
if let access { _ = try self.validatedRecord(access) }
|
|
if let session { try session.withKey { _ in } }
|
|
guard !Task.isCancelled else { throw PasscodeError.cancelled }
|
|
// Once writing begins, preserve the committed result so AccountManager can catch up.
|
|
try self.storage.remove("attempts")
|
|
try self.save(record)
|
|
committed = true
|
|
if let access, (try? access.withKey { _ in true }) != true { session?.invalidate() }
|
|
PasscodeSession.invalidateAll(except: session)
|
|
if Task.isCancelled { session?.invalidate() }
|
|
return (PasscodeCredentialReference(id: record.id, kind: kind), session)
|
|
}
|
|
|
|
public func setProtectionEnabled(_ enabled: Bool, session: PasscodeSession) throws {
|
|
try session.require(scope: .settings, passcode: true)
|
|
try self.serialized {
|
|
var record = try self.validatedRecord(session)
|
|
guard record.kind != nil else {
|
|
throw PasscodeError.authenticationRequired
|
|
}
|
|
record.unprotectedKey = enabled ? nil : try session.withKey { $0 }
|
|
record.protectionEnabled = enabled
|
|
if !enabled, let account = record.biometricAccount {
|
|
record.biometricCleanup.append(account)
|
|
record.biometricAccount = nil
|
|
}
|
|
record.revision &+= 1
|
|
try self.commit(record, retaining: session)
|
|
try self.cleanupBiometrics(record: &record)
|
|
}
|
|
}
|
|
|
|
public func disablePasscode(session: PasscodeSession) throws {
|
|
try self.requirePasscodeManagement(session)
|
|
try self.serialized {
|
|
var record = try self.validatedRecord(session)
|
|
record.unprotectedKey = try session.withKey { $0 }
|
|
record.kind = nil
|
|
record.salt = nil
|
|
record.iterations = nil
|
|
record.wrappedKey = nil
|
|
record.protectionEnabled = false
|
|
record.managedPasscode = true
|
|
if let account = record.biometricAccount {
|
|
record.biometricCleanup.append(account)
|
|
}
|
|
record.biometricAccount = nil
|
|
record.revision &+= 1
|
|
try self.storage.remove("attempts")
|
|
try self.save(record)
|
|
PasscodeSession.invalidateAll()
|
|
try self.cleanupBiometrics(record: &record)
|
|
}
|
|
}
|
|
|
|
private func cleanupBiometrics(record: inout Record) throws {
|
|
if let pending = record.pendingBiometricAccount { record.biometricCleanup.append(pending); record.pendingBiometricAccount = nil }
|
|
for account in record.biometricCleanup { try self.storage.remove(account) }
|
|
if !record.biometricCleanup.isEmpty {
|
|
record.biometricCleanup = []
|
|
try self.save(record, notify: false)
|
|
}
|
|
}
|
|
|
|
public func resumeCleanup() throws {
|
|
try self.serialized {
|
|
guard var record = try self.load() else { return }
|
|
try self.cleanupBiometrics(record: &record)
|
|
}
|
|
}
|
|
|
|
public func disableBiometrics(session: PasscodeSession) throws {
|
|
try session.require(scope: .settings, passcode: true)
|
|
try self.serialized {
|
|
var record = try self.validatedRecord(session)
|
|
if let account = record.biometricAccount { record.biometricCleanup.append(account) }
|
|
record.biometricAccount = nil
|
|
record.revision &+= 1
|
|
try self.commit(record, retaining: session)
|
|
try self.cleanupBiometrics(record: &record)
|
|
}
|
|
}
|
|
|
|
public func enableBiometrics(session: PasscodeSession, context: LAContext) throws {
|
|
try session.require(scope: .settings, passcode: true)
|
|
let account = "biometric." + UUID().uuidString
|
|
try self.serialized {
|
|
var record = try self.validatedRecord(session)
|
|
guard record.protectionEnabled else { throw PasscodeError.authenticationRequired }
|
|
try self.cleanupBiometrics(record: &record)
|
|
record.pendingBiometricAccount = account
|
|
try self.save(record, notify: false)
|
|
try session.withKey { try self.storage.write($0, account: account, biometric: true) }
|
|
}
|
|
var committed = false
|
|
do {
|
|
guard let result = try self.storage.read(account, context: context),
|
|
try session.withKey({ $0 == result }) else { throw PasscodeError.biometricsUnavailable }
|
|
try self.serialized {
|
|
var record = try self.validatedRecord(session)
|
|
guard record.pendingBiometricAccount == account else { throw PasscodeError.staleAuthorization }
|
|
if let previous = record.biometricAccount { record.biometricCleanup.append(previous) }
|
|
record.pendingBiometricAccount = nil
|
|
record.biometricAccount = account
|
|
record.revision &+= 1
|
|
try self.save(record)
|
|
committed = true
|
|
PasscodeSession.invalidateAll(except: session)
|
|
try session.advance(to: record.revision)
|
|
try self.cleanupBiometrics(record: &record)
|
|
}
|
|
} catch {
|
|
// Once the credential points to this key, only obsolete entries may
|
|
// be removed. Failed cleanup remains journaled for resumeCleanup().
|
|
if !committed { try? self.storage.remove(account) }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func authenticateBiometrics(context: LAContext, scope: PasscodeSession.Scope, lifetime: PasscodeSession.Lifetime = .standard) throws -> PasscodeSession {
|
|
guard case let .resource(namespace) = scope, !namespace.isEmpty else { throw PasscodeError.authenticationRequired }
|
|
let record = try self.serialized { () throws -> Record in
|
|
guard let record = try self.load(), record.protectionEnabled, record.biometricAccount != nil else { throw PasscodeError.biometricsUnavailable }
|
|
return record
|
|
}
|
|
guard let key = try self.storage.read(record.biometricAccount!, context: context), key.count == 32 else { throw PasscodeError.biometricsUnavailable }
|
|
let access = PasscodeSession(credentialId: record.id, revision: record.revision, key: key, scope: scope, authentication: .biometrics, lifetime: lifetime)
|
|
do {
|
|
try self.validate(access)
|
|
return access
|
|
} catch {
|
|
access.invalidate()
|
|
throw error
|
|
}
|
|
}
|
|
}
|