1// KeyTransparencyLog.swift2// SophaxChatCore3//4// Persistent log of observed peer identity keys.5// Lets the user audit when (and how many times) a contact's keys have changed.6//7// Storage: UserDefaults "sophax.keylog" + HMAC tag in "sophax.keylog.mac"8// The HMAC key lives in Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)9// so an attacker with filesystem access cannot silently delete entries or add10// fake ones without invalidating the tag — the app clears and re-flags on mismatch.1112import Foundation13import CryptoKit1415// MARK: - Entry1617/// One observed key snapshot for a peer.18public struct KeyLogEntry: Codable, Sendable {19/// peerID of the contact.20 public let peerID: String21/// Ed25519 signing public key observed at this time.22 public let signingKeyPublic: Data23/// X25519 DH public key observed at this time.24 public let dhKeyPublic: Data25/// First time this specific key combination was observed.26 public let firstSeen: Date27/// Most recent time this key was seen in a Hello message.28 public var lastSeen: Date29}3031// MARK: - Log3233/// Append-only log of all observed peer identity keys.34/// Thread-safety: all mutations must happen on the main thread (owned by ChatManager → AppState).35public final class KeyTransparencyLog:@unchecked Sendable {3637 private static let defaultsKey ="sophax.keylog"38 private static let defaultsMACKey ="sophax.keylog.mac"3940/// In-memory cache: peerID → [KeyLogEntry] (chronological).41 private var log:[String:[KeyLogEntry]]=[:]4243 private let macKey: SymmetricKey4445/// Set to true if the stored log failed HMAC verification on last load.46/// ChatManager exposes this to AppState so the UI can warn the user.47 public private(set) var wasLogTampered: Bool = false4849 public init(keychain: KeychainManager){50 self.macKey = keychain.loadOrCreateLogMACKey()51 load()52}5354// MARK: - Public API5556/// Record an observed key pair for `peerID`.57/// - Returns: `true` if the key is NEW or DIFFERENT from the most-recently-seen key,58/// `false` if the same key was already the latest entry (no change).59@discardableResult60 public func record(peerID: String, signingKey: Data, dhKey: Data)-> Bool {61 var entries = log[peerID]??[]6263// Update `lastSeen` if the last entry has the same keys64 if let last = entries.last,65 last.signingKeyPublic == signingKey,66 last.dhKeyPublic == dhKey {67 entries[entries.count - 1].lastSeen = Date()68 log[peerID]= entries69 save()70 return false71}7273// New or changed key — append a new entry74 let entry = KeyLogEntry(75 peerID: peerID,76 signingKeyPublic: signingKey,77 dhKeyPublic: dhKey,78 firstSeen: Date(),79 lastSeen: Date()80)81 entries.append(entry)82// Cap history at 20 entries per peer to bound storage size83 if entries.count > 20 { entries.removeFirst(entries.count - 20)}84 log[peerID]= entries85 save()86 return true87}8889/// Full key history for a peer (oldest first).90 public func history(for peerID: String)->[KeyLogEntry]{91 log[peerID]??[]92}9394/// All logged peerIDs.95 public var allPeerIDs:[String]{ Array(log.keys)}9697// MARK: - Persistence9899 private func save(){100 guard let jsonData = try? JSONEncoder().encode(log) else { return }101// Compute HMAC over the JSON and store tag separately102 let mac = HMAC<SHA256>.authenticationCode(for: jsonData, using: macKey)103 UserDefaults.standard.set(jsonData, forKey: Self.defaultsKey)104 UserDefaults.standard.set(Data(mac), forKey: Self.defaultsMACKey)105}106107 private func load(){108 guard let jsonData = UserDefaults.standard.data(forKey: Self.defaultsKey) else { return }109110// Verify HMAC before accepting any data from UserDefaults111 if let storedMAC = UserDefaults.standard.data(forKey: Self.defaultsMACKey),112 HMAC<SHA256>.isValidAuthenticationCode(storedMAC, authenticating: jsonData, using: macKey),113 let decoded = try? JSONDecoder().decode([String:[KeyLogEntry]].self, from: jsonData){114 log = decoded115} else {116// MAC missing or invalid — treat as tampered; wipe and flag for UI alert117 UserDefaults.standard.removeObject(forKey: Self.defaultsKey)118 UserDefaults.standard.removeObject(forKey: Self.defaultsMACKey)119 wasLogTampered = true120}121}122}