1// PreKeyManager.swift2// SophaxChatCore3//4// Manages X3DH prekeys for the local user.5//6// Prekey types:7// - Signed Prekey (SPK): medium-term X25519 key, rotated every ~7 days8// Signed by the identity signing key to prove authenticity.9// - One-Time Prekeys (OPKs): short-term X25519 keys, each used exactly once10// Provide "break-in recovery" / future secrecy for session establishment.11//12// Reference: https://signal.org/docs/specifications/x3dh/1314import Foundation15import CryptoKit1617// MARK: - Prekey Bundle (shared with peers)1819/// Published by a user to allow others to initiate X3DH sessions.20/// Shared over the mesh when a peer requests to start a session.21public struct PreKeyBundle: Codable, Sendable {22// Identity keys23 public let signingKeyPublic: Data // Ed25519 identity signing public key24 public let dhIdentityKeyPublic: Data // X25519 identity DH public key2526// Signed Prekey (SPK)27 public let signedPreKeyPublic: Data // X25519 SPK public key28 public let signedPreKeySignature: Data // Ed25519 signature of SPK by identity signing key29 public let signedPreKeyId: UInt323031// One-Time Prekey (OPK) — optional, included when available32 public let oneTimePreKeyPublic: Data?33 public let oneTimePreKeyId: UInt32?3435// User info36 public let username: String3738// Timestamp — peers reject bundles older than CryptoConstants.maxPreKeyBundleAge39 public let timestamp: Date4041/// Optional "host:port" TCP address — if present, the peer is reachable over the internet42/// at this address (direct TCP or via SOCKS5/Tor). Nil means BLE/WiFi-local only.43/// Propagated in Hello messages so nearby peers automatically learn internet addresses.44 public let tcpAddress: String?4546/// Optional JPEG avatar (64×64, ≤8 KB). nil = no avatar set.47/// Propagated in Hello messages so contacts see the sender's photo.48 public let avatarData: Data?4950/// MLS KeyPackage for this peer (serialised mls-rs bytes). nil = peer does not support MLS.51/// Group coordinators use this to add the peer to an MLS group via Welcome.52 public let mlsKeyPackage: Data?5354/// ML-KEM-768 public key (1184 bytes). nil = peer is running iOS < 18.55/// When present, initiators include an ML-KEM encapsulated shared secret so the56/// session key is quantum-resistant even if the Curve25519 DH is broken by Shor's algorithm.57 public let pqPreKeyPublic: Data?5859/// SHA-256 fingerprint of the peer's ephemeral TLS session certificate (32 bytes).60/// nil = peer does not advertise TCP or is running a build without TLS cert pinning.61/// When present, TCP connections to this peer MUST verify the remote certificate62/// fingerprint matches — mutual pinning with no CA and no trust chain.63 public let tlsCertFingerprint: Data?6465 public init(66 signingKeyPublic: Data,67 dhIdentityKeyPublic: Data,68 signedPreKeyPublic: Data,69 signedPreKeySignature: Data,70 signedPreKeyId: UInt32,71 oneTimePreKeyPublic: Data?= nil,72 oneTimePreKeyId: UInt32?= nil,73 username: String,74 timestamp: Date,75 tcpAddress: String?= nil,76 avatarData: Data?= nil,77 mlsKeyPackage: Data?= nil,78 pqPreKeyPublic: Data?= nil,79 tlsCertFingerprint: Data?= nil80){81 self.signingKeyPublic = signingKeyPublic82 self.dhIdentityKeyPublic = dhIdentityKeyPublic83 self.signedPreKeyPublic = signedPreKeyPublic84 self.signedPreKeySignature = signedPreKeySignature85 self.signedPreKeyId = signedPreKeyId86 self.oneTimePreKeyPublic = oneTimePreKeyPublic87 self.oneTimePreKeyId = oneTimePreKeyId88 self.username = username89 self.timestamp = timestamp90 self.tcpAddress = tcpAddress91 self.avatarData = avatarData92 self.mlsKeyPackage = mlsKeyPackage93 self.pqPreKeyPublic = pqPreKeyPublic94 self.tlsCertFingerprint = tlsCertFingerprint95}9697/// Verifies the signed prekey signature against the identity key.98/// MUST be called before using the bundle.99///100/// The signature covers `signedPreKeyPublic ‖ spkTimestampData(timestamp)` so that101/// an adversary cannot substitute a freshly minted timestamp onto a captured bundle102/// without invalidating the Ed25519 tag.103 public func verifySignedPreKey() throws -> Bool {104// Explicit length checks before passing to CryptoKit — gives a clear error105// and prevents library-specific exception messages from leaking algorithm details.106 guard signingKeyPublic.count == 32,107 dhIdentityKeyPublic.count == 32,108 signedPreKeyPublic.count == 32,109 signedPreKeySignature.count == 64 else {110 throw SophaxError.invalidMessageFormat("Invalid prekey bundle key dimensions")111}112 let identityKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyPublic)113// Include signingKeyPublic as the first field so an adversary cannot swap in a114// different identity key while reusing a captured (SPK, timestamp, signature) triple.115 let signedData = signingKeyPublic + signedPreKeyPublic + spkTimestampData(timestamp)116 return identityKey.isValidSignature(signedPreKeySignature, for: signedData)117}118119/// Unique peer identifier derived from identity keys.120 public var peerID: String {121 let combined = signingKeyPublic + dhIdentityKeyPublic122 let hash = SHA256.hash(data: combined)123 return Data(hash).prefix(16).hexString124}125}126127// MARK: - Helpers128129/// Canonical 8-byte little-endian representation of a Date's TimeInterval.130/// Used to include the SPK bundle timestamp in the Ed25519 signed data so that131/// the timestamp field cannot be silently modified without invalidating the signature.132///133/// Choosing a raw TimeInterval (Double, 8 bytes) rather than an ISO8601 string avoids134/// locale, formatter-version, and string-encoding ambiguity.135private func spkTimestampData(_ date: Date)-> Data {136 var ti = date.timeIntervalSinceReferenceDate137 return Data(bytes:&ti, count: MemoryLayout<Double>.size)138}139140// MARK: - Prekey Manager141142// Threading contract: all calls must be made from a single thread or under external143// mutual exclusion. ChatManager owns the single instance and accesses it on its144// serial dispatch queue.145public final class PreKeyManager:@unchecked Sendable {146147 private let keychain: KeychainManager148 private let identity: IdentityManager149150 private var signedPreKey: DHKeyPair151 private var signedPreKeyId: UInt32152 private var oneTimePreKeys:[UInt32: DHKeyPair]=[:]153154/// SHA-256 fingerprint of the local TLS session certificate (32 bytes).155/// Set by ChatManager after TLSCertManager.start(). Included in every generated bundle156/// so peers can pin outbound TCP connections to our ephemeral cert.157 public var tlsCertFingerprint: Data?158159// MARK: - Init160161 public init(identity: IdentityManager, keychain: KeychainManager) throws {162 self.identity = identity163 self.keychain = keychain164165// Load or generate signed prekey166 if let (id, key)= try? keychain.loadSignedPreKey(){167 self.signedPreKey = DHKeyPair(privateKey: key)168 self.signedPreKeyId = id169} else {170 let pair = DHKeyPair()171 let id = UInt32.random(in: 1...UInt32.max)172 try keychain.saveSignedPreKey(id: id, key: pair.privateKey)173 try keychain.saveSignedPreKeyDate(Date())174 self.signedPreKey = pair175 self.signedPreKeyId = id176}177178// Generate one-time prekeys179 try generateOneTimePreKeys(count: 20)180}181182// MARK: - Public API183184/// Number of one-time prekeys currently available in the local pool.185 public var opkCount: Int { oneTimePreKeys.count }186187/// Generates a PreKeyBundle ready to share with a peer.188/// - Parameter tcpAddress: Optional "host:port" to include so peers learn our TCP address.189 public func generateBundle(tcpAddress: String?= nil) throws -> PreKeyBundle {190 let pub = identity.publicIdentity191 let spkData = signedPreKey.publicKeyData192// Capture timestamp before signing so the exact same value ends up in both the193// signed data and the bundle field — verifySignedPreKey() reconstructs the signed194// data from bundle.timestamp, so they must match to the millisecond.195 let timestamp = Date()196// signingKeyPublic is prepended so the signature commits to the identity key itself,197// preventing a key-substitution attack (different signingKeyPublic + same SPK/timestamp).198 let signedData = pub.signingKeyPublic + spkData + spkTimestampData(timestamp)199 let spkSignature = try identity.sign(signedData)200 let otp = oneTimePreKeys.randomElement()201202 return PreKeyBundle(203 signingKeyPublic: pub.signingKeyPublic,204 dhIdentityKeyPublic: pub.dhKeyPublic,205 signedPreKeyPublic: spkData,206 signedPreKeySignature: spkSignature,207 signedPreKeyId: signedPreKeyId,208 oneTimePreKeyPublic: otp?.value.publicKeyData,209 oneTimePreKeyId: otp?.key,210 username: pub.username,211 timestamp: timestamp,212 tcpAddress: tcpAddress,213 avatarData: identity.loadAvatar(),214 pqPreKeyPublic: identity.pqPublicKeyData,215 tlsCertFingerprint: tlsCertFingerprint216)217}218219/// Returns and removes a one-time prekey by ID.220/// Call this when Bob processes an incoming session initiation message.221/// Deletes from Keychain first to ensure the key is permanently gone even222/// if the process crashes between the two operations.223 public func consumeOneTimePreKey(id: UInt32) throws -> DHKeyPair?{224 guard let pair = oneTimePreKeys[id] else { return nil }225// Delete from Keychain before removing from memory. If the Keychain delete226// fails, we do NOT consume the key — better to allow retrying than to silently227// leave stale key material that can never be cleaned up.228 try keychain.deleteOneTimePreKey(id: id)229 oneTimePreKeys.removeValue(forKey: id)230 return pair231}232233/// The current signed prekey pair (Bob's initial ratchet key in X3DH).234 public var signedPreKeyPair: DHKeyPair { signedPreKey }235236/// Rotate the signed prekey unconditionally.237 public func rotateSignedPreKey() throws {238 let pair = DHKeyPair()239 let id = UInt32.random(in: 1...UInt32.max)240 try keychain.saveSignedPreKey(id: id, key: pair.privateKey)241 try keychain.saveSignedPreKeyDate(Date())242 signedPreKey = pair243 signedPreKeyId = id244}245246/// Rotate only if the current signed prekey is older than `maxAge` seconds (default 7 days).247 public func rotateIfNeeded(maxAge: TimeInterval = 7 * 24 * 3600) throws {248 let createdAt =(try? keychain.loadSignedPreKeyDate())??.distantPast249 guard Date().timeIntervalSince(createdAt)> maxAge else { return }250 try rotateSignedPreKey()251}252253/// Generate additional one-time prekeys if the supply is below `target / 2`.254/// Call this after consuming a one-time prekey to keep the pool healthy.255 public func replenishIfNeeded(target: Int = 20) throws {256 let current = oneTimePreKeys.count257 guard current < target / 2 else { return }258 try generateOneTimePreKeys(count: target - current)259}260261// MARK: - Private262263 private func generateOneTimePreKeys(count: Int) throws {264// Mutable set tracks both pre-existing and newly generated IDs within this265// batch to prevent (rare) collisions that would silently overwrite a prekey.266 var usedIDs = Set(oneTimePreKeys.keys)267 for _ in 0..<count {268 var id: UInt32269 repeat { id = UInt32.random(in: 1...UInt32.max)} while usedIDs.contains(id)270 usedIDs.insert(id)271 let pair = DHKeyPair()272 oneTimePreKeys[id]= pair273 try keychain.saveOneTimePreKey(id: id, key: pair.privateKey)274}275}276}