1// KeychainManager.swift2// SophaxChatCore3//4// Secure storage for cryptographic keys using the iOS Keychain.5// All keys are stored with kSecAttrAccessibleWhenUnlockedThisDeviceOnly6// — they are not backed up to iCloud and are wiped on device restore.78import Foundation9import Security10import CryptoKit11import CommonCrypto1213public final class KeychainManager {1415 private let service: String16 private let accessGroup: String?1718 public init(service: String ="com.sophax.SophaxChat", accessGroup: String?= nil){19 self.service = service20 self.accessGroup = accessGroup21}2223// MARK: - Identity Signing Key (Ed25519)2425 public func saveSigningKey(_ key: Curve25519.Signing.PrivateKey) throws {26 try save(data: key.rawRepresentation, account:"identity.signing")27}2829 public func loadSigningKey() throws -> Curve25519.Signing.PrivateKey {30 let data = try load(account:"identity.signing")31 return try Curve25519.Signing.PrivateKey(rawRepresentation: data)32}3334// MARK: - Identity DH Key (X25519)3536 public func saveDHIdentityKey(_ key: Curve25519.KeyAgreement.PrivateKey) throws {37 try save(data: key.rawRepresentation, account:"identity.dh")38}3940 public func loadDHIdentityKey() throws -> Curve25519.KeyAgreement.PrivateKey {41 let data = try load(account:"identity.dh")42 return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)43}4445// MARK: - Signed Prekey4647 public func saveSignedPreKey(id: UInt32, key: Curve25519.KeyAgreement.PrivateKey) throws {48 try save(data: key.rawRepresentation, account:"spk.\(id)")49 try save(data: Data(withUnsafeBytes(of: id){ Data($0)}), account:"spk.current_id")50}5152 public func loadSignedPreKey() throws ->(id: UInt32, key: Curve25519.KeyAgreement.PrivateKey){53 let idData = try load(account:"spk.current_id")54 guard idData.count == 4 else { throw SophaxError.keychainError(errSecItemNotFound)}55 let id = idData.withUnsafeBytes { $0.load(as: UInt32.self)}56 let keyData = try load(account:"spk.\(id)")57 let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: keyData)58 return (id, key)59}6061// MARK: - Signed Prekey Creation Date6263/// Stores the Date the current signed prekey was generated.64/// Used to trigger rotation after 7 days.65 public func saveSignedPreKeyDate(_ date: Date) throws {66 var ti = date.timeIntervalSinceReferenceDate67 let data = Data(bytes:&ti, count: MemoryLayout<Double>.size)68 try save(data: data, account:"spk.created_at")69}7071 public func loadSignedPreKeyDate() throws -> Date {72 let data = try load(account:"spk.created_at")73 guard data.count == MemoryLayout<Double>.size else {74 throw SophaxError.keychainError(errSecItemNotFound)75}76 let ti = data.withUnsafeBytes { $0.load(as: Double.self)}77 return Date(timeIntervalSinceReferenceDate: ti)78}7980// MARK: - One-Time Prekeys8182 public func saveOneTimePreKey(id: UInt32, key: Curve25519.KeyAgreement.PrivateKey) throws {83 try save(data: key.rawRepresentation, account:"otpk.\(id)")84}8586 public func loadOneTimePreKey(id: UInt32) throws -> Curve25519.KeyAgreement.PrivateKey {87 let data = try load(account:"otpk.\(id)")88 return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)89}9091 public func deleteOneTimePreKey(id: UInt32) throws {92 try delete(account:"otpk.\(id)")93}9495// MARK: - Session MAC Key (for HMAC-wrapping session state blobs)9697/// Returns the device-specific session MAC key, creating one if absent.98/// Used to detect session-state tampering or cross-device replay attacks.99 private func loadOrCreateSessionMACKey() throws -> SymmetricKey {100 if let data = try? load(account:"session.mac_key"){101 return SymmetricKey(data: data)102}103 let key = SymmetricKey(size:.bits256)104 let keyData = key.withUnsafeBytes { Data($0)}105 try save(data: keyData, account:"session.mac_key")106 return key107}108109// MARK: - Session State110111/// Persist ratchet session state wrapped with a 32-byte HMAC prefix.112/// Format: HMAC-SHA256(macKey, data) || data113 public func saveSessionState(data: Data, peerID: String) throws {114 let macKey = try loadOrCreateSessionMACKey()115 let mac = Data(HMAC<SHA256>.authenticationCode(for: data, using: macKey))116 try save(data: mac + data, account:"session.\(peerID)")117}118119/// Load and verify session state. Throws `SophaxError.sessionStateCorrupted` on HMAC mismatch.120 public func loadSessionState(peerID: String) throws -> Data {121 let raw = try load(account:"session.\(peerID)")122 guard raw.count > 32 else { throw SophaxError.sessionStateCorrupted }123 let mac = raw.prefix(32)124 let data = raw.dropFirst(32)125 let macKey = try loadOrCreateSessionMACKey()126// Constant-time verification — avoids timing side-channel from Data's short-circuit ==127 guard HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: data, using: macKey) else {128 throw SophaxError.sessionStateCorrupted129}130 return data131}132133 public func deleteSessionState(peerID: String) throws {134 try delete(account:"session.\(peerID)")135}136137// MARK: - Message Storage Key138139 public func saveStorageKey(_ key: SymmetricKey) throws {140 let data = key.withUnsafeBytes { Data($0)}141 try save(data: data, account:"storage.master")142}143144 public func loadStorageKey() throws -> SymmetricKey {145 let data = try load(account:"storage.master")146 return SymmetricKey(data: data)147}148149// MARK: - Username150151 public func saveUsername(_ username: String) throws {152 guard let data = username.data(using:.utf8) else {153 throw SophaxError.invalidMessageFormat("Username not UTF-8 encodable")154}155 try save(data: data, account:"user.username")156}157158 public func loadUsername() throws -> String {159 let data = try load(account:"user.username")160 guard let username = String(data: data, encoding:.utf8) else {161 throw SophaxError.invalidMessageFormat("Username not UTF-8 decodable")162}163 return username164}165166// MARK: - Avatar167168 public func saveAvatar(_ data: Data) throws {169 try save(data: data, account:"identity.avatar")170}171172 public func loadAvatar()-> Data?{173 try? load(account:"identity.avatar")174}175176 public func deleteAvatar(){177 try? delete(account:"identity.avatar")178}179180// MARK: - App Lock (Keychain-backed, excluded from iCloud backup)181182 public func saveAppLockEnabled(_ enabled: Bool) throws {183 let data = Data([enabled ? 1 : 0])184 try save(data: data, account:"settings.applock")185}186187 public func loadAppLockEnabled()-> Bool {188 guard let data = try? load(account:"settings.applock"), data.count == 1 else { return false }189 return data[0]== 1190}191192// MARK: - Unlock Attempt Tracking (rate-limiting brute force)193// Format: UInt32 attempts (4B) || Double lockoutTimestamp (8B, 0.0 = no lockout)194195 public func saveUnlockAttempts(_ count: Int, lockedUntil: Date?){196 var buf = Data(count: 12)197 let c = UInt32(min(count, Int(UInt32.max)))198 let t = lockedUntil?.timeIntervalSince1970 ?? 0.0199 buf.withUnsafeMutableBytes { ptr in200 ptr.storeBytes(of: c.bigEndian, toByteOffset: 0, as: UInt32.self)201 ptr.storeBytes(of: t, toByteOffset: 4, as: Double.self)202}203 try? save(data: buf, account:"settings.unlock_attempts")204}205206 public func loadUnlockAttempts()->(count: Int, lockedUntil: Date?){207 guard let buf = try? load(account:"settings.unlock_attempts"), buf.count == 12 else {208 return (0, nil)209}210 let c = buf.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self).bigEndian }211 let t = buf.withUnsafeBytes { $0.load(fromByteOffset: 4, as: Double.self)}212 let locked: Date?= t > 0 ? Date(timeIntervalSince1970: t): nil213 return (Int(c), locked)214}215216// MARK: - Group Keys (v1 legacy — only used for cleanup during leave/wipe)217218 public func saveGroupKey(_ key: SymmetricKey, groupID: String) throws {219 let data = key.withUnsafeBytes { Data($0)}220 try save(data: data, account:"group.key.\(groupID)")221}222223 public func loadGroupKey(groupID: String) throws -> SymmetricKey {224 let data = try load(account:"group.key.\(groupID)")225 return SymmetricKey(data: data)226}227228 public func deleteGroupKey(groupID: String){229 let query:[CFString: Any]=[230 kSecClass: kSecClassGenericPassword,231 kSecAttrService: service,232 kSecAttrAccount:"group.key.\(groupID)"233]234 let status = SecItemDelete(query as CFDictionary)235#if DEBUG236 if status != errSecSuccess && status != errSecItemNotFound {237 print("[Keychain] deleteGroupKey failed: \(status)")238}239#endif240}241242// MARK: - Sender Key States (v2 group messaging)243244/// Save all peer sender key states for a group as a single JSON blob.245/// Key: peerID → SenderKeyState.246 public func savePeerSenderKeyStates(_ states:[String: SenderKeyState], groupID: String) throws {247 try saveJSON(states, account:"skd.peers.\(groupID)")248}249250/// Load peer sender key states; returns empty dict if none stored yet.251 public func loadPeerSenderKeyStates(groupID: String)->[String: SenderKeyState]{252 loadJSON([String: SenderKeyState].self, account:"skd.peers.\(groupID)")??[:]253}254255 public func saveMySenderKeyState(_ state: SenderKeyState, groupID: String) throws {256 try saveJSON(state, account:"skd.mine.\(groupID)")257}258259/// Returns nil if no sender key has been generated for this group yet.260 public func loadMySenderKeyState(groupID: String)-> SenderKeyState?{261 loadJSON(SenderKeyState.self, account:"skd.mine.\(groupID)")262}263264// MARK: - Skipped group message keys (out-of-order persistence)265266/// A single cached message key together with when it was stored.267/// Used to evict old entries on load without tracking timestamps in memory.268 public struct SkippedKeyEntry: Codable, Sendable {269 public let keyData: Data270 public let storedAt: Date271}272273/// Persist the full skipped-message-key cache to Keychain.274/// `entries` maps cacheKey ("groupID/senderPeerID") → (iteration → entry).275/// Iteration keys are stored as strings because JSON requires string keys.276 public func saveSkippedGroupKeys(_ entries:[String:[String: SkippedKeyEntry]]) throws {277 if entries.isEmpty {278 try? delete(account:"skd.skipped.all")279 return280}281 try saveJSON(entries, account:"skd.skipped.all")282}283284/// Load the skipped-message-key cache, discarding entries older than 7 days.285 public func loadSkippedGroupKeys()->[String:[String: SkippedKeyEntry]]{286 guard let decoded = loadJSON([String:[String: SkippedKeyEntry]].self, account:"skd.skipped.all") else { return [:]}287 let cutoff = Date().addingTimeInterval(-7 * 24 * 3600)288 return decoded.mapValues { inner in289 inner.filter { $0.value.storedAt > cutoff }290}.filter {!$0.value.isEmpty }291}292293/// Delete all sender key material for a group (called on leave).294 public func deleteAllSenderKeyStates(groupID: String){295 let q1:[CFString: Any]=[kSecClass: kSecClassGenericPassword,296 kSecAttrService: service,297 kSecAttrAccount:"skd.peers.\(groupID)"]298 let s1 = SecItemDelete(q1 as CFDictionary)299 let q2:[CFString: Any]=[kSecClass: kSecClassGenericPassword,300 kSecAttrService: service,301 kSecAttrAccount:"skd.mine.\(groupID)"]302 let s2 = SecItemDelete(q2 as CFDictionary)303#if DEBUG304 for (label, status) in [("peers", s1),("mine", s2)] where305 status != errSecSuccess && status != errSecItemNotFound {306 print("[Keychain] deleteAllSenderKeyStates(\(label)) failed: \(status)")307}308#endif309}310311// MARK: - Verified Peers (Safety Number pinning)312313/// Persist the peerID → safetyNumber map in the Keychain so verification314/// state survives app restarts without being readable from UserDefaults backups.315 public func saveVerifiedPeers(_ peers:[String: String]) throws {316 try saveJSON(peers, account:"verified.peers")317}318319/// Returns the persisted peerID → safetyNumber map, or [:] if none stored yet.320 public func loadVerifiedPeers()->[String: String]{321 loadJSON([String: String].self, account:"verified.peers")??[:]322}323324// MARK: - Pinned Messages (conversationID → messageID)325326/// Persist the conversationID → pinned messageID map in the Keychain.327 public func savePinnedMessages(_ map:[String: String]) throws {328 try saveJSON(map, account:"pinned.messages")329}330331/// Returns the persisted conversationID → pinned messageID map, or [:] if none stored.332 public func loadPinnedMessages()->[String: String]{333 loadJSON([String: String].self, account:"pinned.messages")??[:]334}335336// MARK: - Peer Aliases (user-assigned contact nicknames)337// Moved from UserDefaults to Keychain to exclude from iCloud/iTunes backups.338339 public func savePeerAliases(_ aliases:[String: String]) throws {340 try saveJSON(aliases, account:"peer.aliases")341}342343 public func loadPeerAliases()->[String: String]{344 loadJSON([String: String].self, account:"peer.aliases")??[:]345}346347// MARK: - Blocked Peers348// Moved from UserDefaults to Keychain to exclude from iCloud/iTunes backups.349350 private struct BlockedPeersPayload: Codable {351 let ids:[String]352 let names:[String: String]353}354355 public func saveBlockedPeers(_ ids: Set<String>, names:[String: String]) throws {356 try saveJSON(BlockedPeersPayload(ids: Array(ids), names: names), account:"blocked.peers")357}358359 public func loadBlockedPeers()->(ids: Set<String>, names:[String: String]){360 guard let data = try? load(account:"blocked.peers") else { return ([],[:])}361// Try new format first362 if let payload = try? JSONDecoder().decode(BlockedPeersPayload.self, from: data){363 return (Set(payload.ids), payload.names)364}365// Migrate from legacy parallel-array format366 if let raw = try? JSONDecoder().decode([String:[String]].self, from: data),367 let ids = raw["ids"],368 let keys = raw["names_keys"],369 let vals = raw["names_vals"],370 keys.count == vals.count {371 let names = Dictionary(uniqueKeysWithValues: zip(keys, vals))372 try? saveBlockedPeers(Set(ids), names: names)373 return (Set(ids), names)374}375 return ([],[:])376}377378// MARK: - MLS Group State (Phase 2 — raw binary blob from mls-rs)379380/// Persist serialised MLS group state for a group.381/// State is a raw binary blob produced by mls-rs — stored as-is (no JSON wrapper).382 public func saveMlsGroupState(_ state: Data, groupID: String) throws {383 try save(data: state, account:"mls.state.\(groupID)")384}385386/// Returns the stored MLS group state, or nil if the group has never been joined.387 public func loadMlsGroupState(groupID: String)-> Data?{388 try? load(account:"mls.state.\(groupID)")389}390391/// Remove the MLS group state for a group (called on leave or wipe).392 public func deleteMlsGroupState(groupID: String) throws {393 try delete(account:"mls.state.\(groupID)")394}395396// MARK: - SSS Backup (Shamir's Secret Sharing)397398/// Store a plaintext SSS share received from a backup creator (holder role).399/// Appends to the list of all held shares; safe to call for multiple creators.400 public func saveSSSShare(_ share: SSSShare){401 var shares = loadSSSShares()402 shares.removeAll { $0.id == share.id }// replace if re-sent403 shares.append(share)404 try? saveJSON(shares, account:"sss.shares")405}406407/// All SSS shares currently held for other users' backup recovery.408 public func loadSSSShares()->[SSSShare]{409 loadJSON([SSSShare].self, account:"sss.shares")??[]410}411412/// Remove a specific share after the creator has confirmed recovery is complete.413 public func deleteSSSShare(shareID: String){414 var shares = loadSSSShares()415 shares.removeAll { $0.id == shareID }416 try? saveJSON(shares, account:"sss.shares")417}418419/// Store the creator's backup manifest (which contacts hold which shares).420 public func saveSSSBackupManifest(_ manifest: SSSBackupManifest){421 try? saveJSON(manifest, account:"sss.manifest")422}423424 public func loadSSSBackupManifest()-> SSSBackupManifest?{425 loadJSON(SSSBackupManifest.self, account:"sss.manifest")426}427428/// Delete all SSS data — called by wipeAllData().429 public func deleteAllSSSData(){430 try? delete(account:"sss.shares")431 try? delete(account:"sss.manifest")432}433434// MARK: - Key Transparency Log MAC Key435436/// Load or create a stable 256-bit HMAC key used to authenticate the key transparency log.437/// Stored in Keychain so it is device-local and inaccessible while locked.438 public func loadOrCreateLogMACKey()-> SymmetricKey {439 if let data = try? load(account:"keylog.mac"),440 data.count == 32 {441 return SymmetricKey(data: data)442}443 let key = SymmetricKey(size:.bits256)444 _ = try? key.withUnsafeBytes { try save(data: Data($0), account:"keylog.mac")}445 return key446}447448// MARK: - PQ Identity Key (ML-KEM-768, iOS 18+ / macOS 15+)449// Stored as raw seed bytes (platform-specific length).450// The caller is responsible for availability guards — this layer is version-agnostic.451452 public func savePQIdentityKey(_ data: Data) throws {453 try save(data: data, account:"identity.pq")454}455456 public func loadPQIdentityKey()-> Data?{457 try? load(account:"identity.pq")458}459460 public func deletePQIdentityKey(){461 try? delete(account:"identity.pq")462}463464// MARK: - Existence Check465466 public func hasIdentity()-> Bool {467 return (try? loadSigningKey())!= nil468}469470// MARK: - App Lock PIN (custom numeric PIN, alternative to biometrics)471//472// Stored as PBKDF2-HMAC-SHA256(pin, salt, 100k iterations).473// 100k iterations ≈ 0.1s on A15+: acceptable UX, ~100k× harder to brute-force than SHA256.474//475// Migration: v1 (SHA256) format is detected by the legacy account keys. On first476// successful v1 verify the hash is automatically upgraded to PBKDF2 and the old477// keys are deleted. New installs write PBKDF2 only.478479 private static let lockPINIterations = 720_000480 private static let lockPINHashAccountV2 ="settings.lock_pin_v2"481 private static let lockPINSaltAccountV2 ="settings.lock_pin_s_v2"482 private static let lockPINHashAccountV1 ="settings.lock_pin"483 private static let lockPINSaltAccountV1 ="settings.lock_pin_s"484485 public func saveRealLockPIN(_ pin: String) throws {486 let salt = SymmetricKey(size:.bits256).withUnsafeBytes { Data($0)}487 let hash = try pinHash(pin: pin, salt: salt, iterations: Self.lockPINIterations)488 try save(data: salt, account: Self.lockPINSaltAccountV2)489 try save(data: hash, account: Self.lockPINHashAccountV2)490 try? delete(account: Self.lockPINHashAccountV1)491 try? delete(account: Self.lockPINSaltAccountV1)492}493494 public func verifyRealLockPIN(_ pin: String)-> Bool {495 if let salt = try? load(account: Self.lockPINSaltAccountV2),496 let stored = try? load(account: Self.lockPINHashAccountV2),497 let computed = try? pinHash(pin: pin, salt: salt, iterations: Self.lockPINIterations){498 return timingsafeEqual(computed, stored)499}500// Fall back to legacy SHA256 (v1) — upgrade on success501 guard let salt = try? load(account: Self.lockPINSaltAccountV1),502 let stored = try? load(account: Self.lockPINHashAccountV1) else { return false }503 var input = Data(salt)504 input.append(contentsOf: pin.utf8)505 let computed = Data(SHA256.hash(data: input))506 guard timingsafeEqual(computed, stored) else { return false }507 try? saveRealLockPIN(pin)508 return true509}510511 public func clearRealLockPIN(){512 try? delete(account: Self.lockPINHashAccountV2)513 try? delete(account: Self.lockPINSaltAccountV2)514 try? delete(account: Self.lockPINHashAccountV1)515 try? delete(account: Self.lockPINSaltAccountV1)516}517518 public func hasRealLockPIN()-> Bool {519(try? load(account: Self.lockPINHashAccountV2))!= nil ||520(try? load(account: Self.lockPINHashAccountV1))!= nil521}522523// MARK: - Duress PIN524//525// Stored as PBKDF2-HMAC-SHA256(pin, salt, 100k iterations) under obfuscated account keys.526// PIN is NEVER stored in plaintext, memory, or UserDefaults.527528 private static let duressHashAccountV2 ="settings.security_alt_v2"529 private static let duressSaltAccountV2 ="settings.security_alt_s_v2"530 private static let duressHashAccountV1 ="settings.security_alt"531 private static let duressSaltAccountV1 ="settings.security_alt_s"532533 public func saveDuressPIN(_ pin: String) throws {534 let salt = SymmetricKey(size:.bits256).withUnsafeBytes { Data($0)}535 let hash = try pinHash(pin: pin, salt: salt, iterations: Self.lockPINIterations)536 try save(data: salt, account: Self.duressSaltAccountV2)537 try save(data: hash, account: Self.duressHashAccountV2)538 try? delete(account: Self.duressHashAccountV1)539 try? delete(account: Self.duressSaltAccountV1)540}541542 public func verifyDuressPIN(_ pin: String)-> Bool {543 if let salt = try? load(account: Self.duressSaltAccountV2),544 let stored = try? load(account: Self.duressHashAccountV2),545 let computed = try? pinHash(pin: pin, salt: salt, iterations: Self.lockPINIterations){546 return timingsafeEqual(computed, stored)547}548// Fall back to SHA256 v1 — upgrade on success549 guard let salt = try? load(account: Self.duressSaltAccountV1),550 let stored = try? load(account: Self.duressHashAccountV1) else { return false }551 var input = Data(salt)552 input.append(contentsOf: pin.utf8)553 let computed = Data(SHA256.hash(data: input))554 guard timingsafeEqual(computed, stored) else { return false }555 try? saveDuressPIN(pin)556 return true557}558559 public func clearDuressPIN(){560 try? delete(account: Self.duressHashAccountV2)561 try? delete(account: Self.duressSaltAccountV2)562 try? delete(account: Self.duressHashAccountV1)563 try? delete(account: Self.duressSaltAccountV1)564}565566 public func hasDuressPIN()-> Bool {567(try? load(account: Self.duressHashAccountV2))!= nil ||568(try? load(account: Self.duressHashAccountV1))!= nil569}570571// MARK: - Remote Wipe Dedup572// Persisted across restarts to block replay attacks on remote wipe requests.573// Each entry carries a timestamp so records older than 30 days can be pruned —574// they offer no replay protection value once the device clock has advanced past them.575576 private struct WipeRequestRecord: Codable {577 let id: String578 let seenAt: Date579}580581/// Persist seen wipe request IDs with their reception timestamps.582/// IDs older than 30 days are dropped on save; only new IDs (absent from existing records)583/// are appended with the current time so the stored seenAt is always the first-seen time.584 public func saveSeenWipeRequestIDs(_ ids:[String: Date]){585 let cutoff = Date().addingTimeInterval(-30 * 24 * 3600)586 var records =(loadJSON([WipeRequestRecord].self, account:"wipe.seen_ids")??[])587.filter { $0.seenAt > cutoff }588 let existing = Set(records.map { $0.id })589 for (id, seenAt) in ids where !existing.contains(id){590 records.append(WipeRequestRecord(id: id, seenAt: seenAt))591}592 try? saveJSON(records, account:"wipe.seen_ids")593}594595/// Load seen wipe request IDs as a dictionary of id → first-seen timestamp.596/// IDs older than 30 days are excluded.597 public func loadSeenWipeRequestIDs()->[String: Date]{598 let cutoff = Date().addingTimeInterval(-30 * 24 * 3600)599 let records = loadJSON([WipeRequestRecord].self, account:"wipe.seen_ids")??[]600 return Dictionary(601 uniqueKeysWithValues: records602.filter { $0.seenAt > cutoff }603.map {($0.id, $0.seenAt)}604)605}606607// MARK: - Linked Devices608// Stored in Keychain (not UserDefaults) to exclude from iCloud/iTunes backups.609610 public func saveLinkedDevices(_ peerIDs:[String]){611 try? saveJSON(peerIDs, account:"linked.devices")612}613614 public func loadLinkedDevices()->[String]{615 loadJSON([String].self, account:"linked.devices")??[]616}617618// MARK: - Trusted Wipe Peers619620/// Save the list of peerIDs that are authorised to trigger a remote wipe.621 public func saveTrustedWipePeers(_ peers:[String]){622 try? saveJSON(peers, account:"trustedWipePeers")623}624625/// Load the list of peerIDs authorised to trigger a remote wipe.626 public func loadTrustedWipePeers()->[String]{627 loadJSON([String].self, account:"trustedWipePeers")??[]628}629630 public func wipeAll() throws {631 let query:[CFString: Any]=[632 kSecClass: kSecClassGenericPassword,633 kSecAttrService: service634]635 let status = SecItemDelete(query as CFDictionary)636 guard status == errSecSuccess || status == errSecItemNotFound else {637 throw SophaxError.keychainError(status)638}639}640641// MARK: - Private helpers642643 private func saveJSON<T: Encodable>(_ value: T, account: String) throws {644 try save(data: JSONEncoder().encode(value), account: account)645}646647 private func loadJSON<T: Decodable>(_ type: T.Type, account: String)-> T?{648(try? load(account: account)).flatMap { try? JSONDecoder().decode(type, from: $0)}649}650651 private func save(data: Data, account: String) throws {652 let query = baseQuery(account: account)653 let update:[CFString: Any]=[654 kSecValueData: data,655// kSecAttrAccessibleWhenUnlockedThisDeviceOnly:656// - Items NOT backed up to iCloud657// - Items NOT transferred to new device658// - Accessible only when device is unlocked659 kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly660]661662 var status = SecItemUpdate(query as CFDictionary, update as CFDictionary)663 if status == errSecItemNotFound {664 var addQuery = query665 addQuery[kSecValueData]= data666 addQuery[kSecAttrAccessible]= kSecAttrAccessibleWhenUnlockedThisDeviceOnly667 status = SecItemAdd(addQuery as CFDictionary, nil)668}669670 guard status == errSecSuccess else {671 throw SophaxError.keychainError(status)672}673}674675 private func load(account: String) throws -> Data {676 var query = baseQuery(account: account)677 query[kSecReturnData]= true678 query[kSecMatchLimit]= kSecMatchLimitOne679680 var result: AnyObject?681 let status = SecItemCopyMatching(query as CFDictionary,&result)682683 guard status == errSecSuccess, let data = result as? Data else {684 throw SophaxError.keychainError(status)685}686 return data687}688689 private func delete(account: String) throws {690 let query = baseQuery(account: account)691 let status = SecItemDelete(query as CFDictionary)692 guard status == errSecSuccess || status == errSecItemNotFound else {693 throw SophaxError.keychainError(status)694}695}696697 private func baseQuery(account: String)->[CFString: Any]{698 var query:[CFString: Any]=[699 kSecClass: kSecClassGenericPassword,700 kSecAttrService: service,701 kSecAttrAccount: account,702// Forces the data-protection keychain on macOS Catalyst (no-op on iOS where703// data-protection is always used). Without this flag Mac builds fall back to704// the file-based keychain which has weaker at-rest encryption.705 kSecUseDataProtectionKeychain: true706]707 if let accessGroup {708 query[kSecAttrAccessGroup]= accessGroup709}710 return query711}712713// MARK: - Private: PIN KDF helpers714715/// PBKDF2-HMAC-SHA256 for PIN storage. Returns 32 derived bytes.716 private func pinHash(pin: String, salt: Data, iterations: Int) throws -> Data {717 var passData = Data(pin.utf8)718 defer { passData.resetBytes(in: 0..<passData.count)}719 var derived = Data(repeating: 0, count: 32)720 let status: CCStatus = derived.withUnsafeMutableBytes { derivedPtr in721 salt.withUnsafeBytes { saltPtr in722 passData.withUnsafeBytes { passPtr in723 CCKeyDerivationPBKDF(724 CCPBKDFAlgorithm(kCCPBKDF2),725 passPtr.baseAddress, passData.count,726 saltPtr.baseAddress, salt.count,727 CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),728 UInt32(iterations),729 derivedPtr.baseAddress, 32730)731}732}733}734 guard status == kCCSuccess else { throw SophaxError.keyGenerationFailed }735 return derived736}737738/// Constant-time equality — prevents timing side-channel on PIN comparison.739 private func timingsafeEqual(_ a: Data, _ b: Data)-> Bool {740 guard a.count == b.count else { return false }741 return a.withUnsafeBytes { ap in742 b.withUnsafeBytes { bp in743 timingsafe_bcmp(ap.baseAddress!, bp.baseAddress!, ap.count)== 0744}745}746}747}