1// IdentityManager.swift2// SophaxChatCore3//4// Manages the user's cryptographic identity.5//6// Identity = two key pairs:7// 1. Signing key pair (Ed25519) — for identity proofs and prekey signatures8// 2. DH key pair (X25519) — for X3DH key agreement9//10// Both keys are persisted in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.11// The identity is stable across app launches.1213import Foundation14import CryptoKit1516// Threading contract: all calls must happen on the same (main) thread or under17// external mutual exclusion. The class holds mutable state (cached public identity)18// that is not internally synchronized. In practice, ChatManager owns the single19// IdentityManager instance and accesses it serially on its dispatch queue.20public final class IdentityManager:@unchecked Sendable {2122// MARK: - Post-Quantum Identity Key (ML-KEM-768)23//24// Stored as raw seed Data to avoid `@available` annotations propagating through25// the entire class. The `pqKeyData` property holds the serialised private key bytes26// (nil on iOS 17); callers use availability-guarded accessors below.2728/// Raw seed bytes of the ML-KEM-768 private key. Set during init on iOS 18+.29 private var pqKeyData: Data?3031/// The ML-KEM-768 public key raw representation (1184 bytes), or nil when32/// compiled with Swift < 6.2 (Xcode < 17 / macOS < 26) or at runtime on iOS < 19.33 public var pqPublicKeyData: Data?{34#if swift(>=6.2)35 guard #available(iOS 19.0, macOS 26.0,*), let rep = pqKeyData else { return nil }36 return (try? MLKEM768.PrivateKey(integrityCheckedRepresentation: rep))?.publicKey.rawRepresentation37#else38 return nil39#endif40}4142/// Decapsulate an ML-KEM-768 encapsulated key produced by a remote peer.43/// Only callable when compiled with Swift 6.2+ (Xcode 17 / macOS 26 SDK).44@available(iOS 19.0, macOS 26.0,*)45 public func pqDecapsulate(_ encapsulatedKeyData: Data) throws -> SymmetricKey {46#if swift(>=6.2)47 guard let rep = pqKeyData,48 let privKey = try? MLKEM768.PrivateKey(integrityCheckedRepresentation: rep) else {49 throw SophaxError.keyGenerationFailed50}51 return try privKey.decapsulate(encapsulatedKeyData)52#else53 throw SophaxError.keyGenerationFailed54#endif55}5657// MARK: - Public Identity (safe to share)5859 public struct PublicIdentity: Codable, Equatable {60/// Display name chosen by the user.61 public let username: String62/// Ed25519 signing public key (32 bytes).63 public let signingKeyPublic: Data64/// X25519 DH public key (32 bytes).65 public let dhKeyPublic: Data66/// Human-readable safety number (fingerprint) for out-of-band verification.67 public let safetyNumber: String6869 public var signingKey: Curve25519.Signing.PublicKey?{70 try? Curve25519.Signing.PublicKey(rawRepresentation: signingKeyPublic)71}7273 public var dhKey: Curve25519.KeyAgreement.PublicKey?{74 try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: dhKeyPublic)75}7677/// Full 256-bit SHA256 hash of signingKeyPublic || dhKeyPublic.78/// Used as the Kademlia DHT node ID for XOR-distance routing.79/// The first 16 hex characters equal `peerID`.80 public var dhtNodeID: Data {81 let combined = signingKeyPublic + dhKeyPublic82 return Data(SHA256.hash(data: combined))83}8485/// A stable, unique peer identifier derived from the identity keys.86/// Used as the MultipeerConnectivity display name and as Keychain account suffix.87 public var peerID: String {88 dhtNodeID.prefix(8).hexString89}90}9192// MARK: - Private state9394 private let keychain: KeychainManager95 private var signingPair: SigningKeyPair96 private var dhPair: DHKeyPair97 public private(set) var publicIdentity: PublicIdentity9899// MARK: - Init100101 public init(keychain: KeychainManager) throws {102 self.keychain = keychain103104// Load or generate key pairs before assigning to self.105// Using a static helper so Swift is satisfied that all stored106// properties are initialised before any instance method is called.107 let (signing, dh)= try IdentityManager.loadOrCreateKeys(keychain: keychain)108 self.signingPair = SigningKeyPair(privateKey: signing)109 self.dhPair = DHKeyPair(privateKey: dh)110111 let username =(try? keychain.loadUsername())??"Anonymous"112 self.publicIdentity = IdentityManager.buildStaticPublicIdentity(113 username: username,114 signing: self.signingPair,115 dh: self.dhPair116)117118// Post-quantum key (iOS 18+ only). On older OS this is simply nil; the119// classical X3DH path runs unchanged.120#if swift(>=6.2)121 if #available(iOS 19.0, macOS 26.0,*){122 if let existing = keychain.loadPQIdentityKey(){123 self.pqKeyData = existing124} else if let pqKey = try? MLKEM768.PrivateKey(){125 let rep = pqKey.integrityCheckedRepresentation126 try? keychain.savePQIdentityKey(rep)127 self.pqKeyData = rep128}129}130#endif131}132133/// Loads existing identity keys from the Keychain, or generates and saves134/// new ones on first launch. Throws on Keychain write errors.135 private static func loadOrCreateKeys(136 keychain: KeychainManager137) throws ->(signing: Curve25519.Signing.PrivateKey,138 dh: Curve25519.KeyAgreement.PrivateKey){139 if let s = try? keychain.loadSigningKey(),140 let d = try? keychain.loadDHIdentityKey(){141 return (s, d)142}143// First launch — generate identity key pairs144 let signing = Curve25519.Signing.PrivateKey()145 let dh = Curve25519.KeyAgreement.PrivateKey()146 try keychain.saveSigningKey(signing)147 try keychain.saveDHIdentityKey(dh)148 return (signing, dh)149}150151// MARK: - Public API152153// MARK: - Avatar154155 public func setAvatar(_ data: Data) throws {156 try keychain.saveAvatar(data)157}158159 public func loadAvatar()-> Data?{160 keychain.loadAvatar()161}162163 public func deleteAvatar(){164 keychain.deleteAvatar()165}166167 public func setUsername(_ username: String) throws {168 let trimmed = username.trimmingCharacters(in:.whitespacesAndNewlines)169 guard !trimmed.isEmpty, trimmed.count <= 64 else {170 throw SophaxError.invalidMessageFormat("Username must be 1–64 characters")171}172 try keychain.saveUsername(trimmed)173 publicIdentity = IdentityManager.buildStaticPublicIdentity(174 username: trimmed, signing: signingPair, dh: dhPair175)176}177178 public var signingKeyPair: SigningKeyPair { signingPair }179 public var dhKeyPair: DHKeyPair { dhPair }180181/// Raw 32-byte Ed25519 private key — used only by IdentityExportManager for backup.182 public func signingPrivateKeyData() throws -> Data {183 signingPair.privateKey.rawRepresentation184}185186/// Raw 32-byte X25519 DH private key — used only by IdentityExportManager for backup.187 public func dhPrivateKeyData() throws -> Data {188 dhPair.privateKey.rawRepresentation189}190191/// SHA256 hex fingerprint of the identity keys — used in backup to detect cross-identity restores.192 public var identityFingerprint: String {193 let combined = publicIdentity.signingKeyPublic + publicIdentity.dhKeyPublic194 return Data(SHA256.hash(data: combined)).hexString195}196197/// The Tor v3 .onion hostname derived from the identity's Ed25519 signing key.198/// Permanent and deterministic — equal to `<peerID>.onion` in structure but199/// computed via the Tor spec (SHA3-256 checksum + base32). Returns nil only if200/// the key is somehow not 32 bytes, which cannot happen in practice.201 public var onionHostname: String?{202 try? OnionAddress.from(ed25519PublicKey: publicIdentity.signingKeyPublic)203}204205/// Sign arbitrary data with the identity signing key.206 public func sign(_ data: Data) throws -> Data {207 let sig = try signingPair.privateKey.signature(for: data)208 return sig209}210211/// Verify a signature made by a given signing public key.212 public static func verify(213 signature: Data,214 for data: Data,215 signingKeyPublic: Data216) throws -> Bool {217 let key = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyPublic)218 return key.isValidSignature(signature, for: data)219}220221// MARK: - Private helpers222223 private static func buildStaticPublicIdentity(224 username: String,225 signing: SigningKeyPair,226 dh: DHKeyPair227)-> PublicIdentity {228 let safetyNumber = generateSafetyNumberStatic(229 signing: signing.publicKeyData, dh: dh.publicKeyData230)231 return PublicIdentity(232 username: username,233 signingKeyPublic: signing.publicKeyData,234 dhKeyPublic: dh.publicKeyData,235 safetyNumber: safetyNumber236)237}238239/// Generates a 60-character safety number split into 12 groups of 5 digits.240/// Used for out-of-band identity verification (read aloud or compare QR codes).241 private static func generateSafetyNumberStatic(signing: Data, dh: Data)-> String {242// Domain separator prevents another app that uses the same raw key material from243// producing the same safety number, and makes the hash input unambiguous.244 let domainSep = Data("SophaxChat_SafetyNumber_v1".utf8)245 let combined = domainSep + signing + dh246 let hash = SHA512.hash(data: combined)247 let hashData = Data(hash)248// SHA512 produces 64 bytes; we consume the first 30.249 precondition(hashData.count >= 30,"SHA512 produced invalid output length")250251 var groups:[String]=[]252 for i in stride(from: 0, to: 30, by: 5){253 let chunk = hashData[i..<(i + 5)]254 let value = chunk.reduce(UInt64(0)){($0 << 8)| UInt64($1)}% 100_000255 groups.append(String(format:"%05d", value))256}257 return groups.joined(separator:" ")258}259}260261// MARK: - Data hex helper262263extension Data {264 var hexString: String {265 map { String(format:"%02x", $0)}.joined()266}267}