import Foundation
import IrdP2PCrypto
#if canImport(Security)
import Security
#endif
/// Where the identity seed lives between launches.
public protocol IdentityStore: Sendable {
func load(account: String) throws -> Identity?
func save(_ identity: Identity, account: String) throws
func delete(account: String) throws
}
public enum IdentityStoreError: Error, Sendable, Equatable {
case keychain(status: Int32)
case corrupt
}
/// Keeps identities in memory only — for tests and previews.
public final class InMemoryIdentityStore: IdentityStore, @unchecked Sendable {
private let lock = NSLock()
private var seeds: [String: String] = [:]
public init() {}
public func load(account: String) throws -> Identity? {
guard let seed = lock.withLock({ seeds[account] }) else { return nil }
return try Identity(seedBase64URL: seed)
}
public func save(_ identity: Identity, account: String) throws { lock.withLock { seeds[account] = identity.seedBase64URL } }
public func delete(account: String) throws { _ = lock.withLock { seeds.removeValue(forKey: account) } }
}
#if canImport(Security)
/// Stores the seed as a generic password item (this device only, after first unlock).
public struct KeychainIdentityStore: IdentityStore {
public var service: String
public var accessGroup: String?
public init(service: String = "ro.ird.p2p.identity", accessGroup: String? = nil) {
self.service = service
self.accessGroup = accessGroup
}
private func query(_ account: String) -> [String: Any] {
var q: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account]
if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
return q
}
public func load(account: String) throws -> Identity? {
var q = query(account)
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(q as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw IdentityStoreError.keychain(status: status) }
guard let data = item as? Data, let seed = String(data: data, encoding: .utf8) else { throw IdentityStoreError.corrupt }
do { return try Identity(seedBase64URL: seed) } catch { throw IdentityStoreError.corrupt }
}
public func save(_ identity: Identity, account: String) throws {
let data = Data(identity.seedBase64URL.utf8)
var add = query(account)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
if status == errSecDuplicateItem {
let update = SecItemUpdate(query(account) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard update == errSecSuccess else { throw IdentityStoreError.keychain(status: update) }
return
}
guard status == errSecSuccess else { throw IdentityStoreError.keychain(status: status) }
}
public func delete(account: String) throws {
let status = SecItemDelete(query(account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw IdentityStoreError.keychain(status: status) }
}
}
#endif
public extension IdentityStore {
/// Loads the identity for `account`, generating and saving one when missing.
func loadOrCreate(account: String = "default") throws -> Identity {
if let existing = try load(account: account) { return existing }
let fresh = Identity.generate()
try save(fresh, account: account)
return fresh
}
}