SophaxChat · Code Richard Metzner

03 · Who you are

Sources/SophaxChatCore/Crypto/IdentityManager.swift

267 lines · Swift

// IdentityManager.swift
// SophaxChatCore
//
// Manages the user's cryptographic identity.
//
// Identity = two key pairs:
//   1. Signing key pair (Ed25519) — for identity proofs and prekey signatures
//   2. DH key pair (X25519)       — for X3DH key agreement
//
// Both keys are persisted in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
// The identity is stable across app launches.

import Foundation
import CryptoKit

// Threading contract: all calls must happen on the same (main) thread or under
// external mutual exclusion. The class holds mutable state (cached public identity)
// that is not internally synchronized. In practice, ChatManager owns the single
// IdentityManager instance and accesses it serially on its dispatch queue.
public final class IdentityManager: @unchecked Sendable {

    // MARK: - Post-Quantum Identity Key (ML-KEM-768)
    //
    // Stored as raw seed Data to avoid `@available` annotations propagating through
    // the entire class. The `pqKeyData` property holds the serialised private key bytes
    // (nil on iOS 17); callers use availability-guarded accessors below.

    /// Raw seed bytes of the ML-KEM-768 private key. Set during init on iOS 18+.
    private var pqKeyData: Data?

    /// The ML-KEM-768 public key raw representation (1184 bytes), or nil when
    /// compiled with Swift < 6.2 (Xcode < 17 / macOS < 26) or at runtime on iOS < 19.
    public var pqPublicKeyData: Data? {
        #if swift(>=6.2)
        guard #available(iOS 19.0, macOS 26.0, *), let rep = pqKeyData else { return nil }
        return (try? MLKEM768.PrivateKey(integrityCheckedRepresentation: rep))?.publicKey.rawRepresentation
        #else
        return nil
        #endif
    }

    /// Decapsulate an ML-KEM-768 encapsulated key produced by a remote peer.
    /// Only callable when compiled with Swift 6.2+ (Xcode 17 / macOS 26 SDK).
    @available(iOS 19.0, macOS 26.0, *)
    public func pqDecapsulate(_ encapsulatedKeyData: Data) throws -> SymmetricKey {
        #if swift(>=6.2)
        guard let rep = pqKeyData,
              let privKey = try? MLKEM768.PrivateKey(integrityCheckedRepresentation: rep) else {
            throw SophaxError.keyGenerationFailed
        }
        return try privKey.decapsulate(encapsulatedKeyData)
        #else
        throw SophaxError.keyGenerationFailed
        #endif
    }

    // MARK: - Public Identity (safe to share)

    public struct PublicIdentity: Codable, Equatable {
        /// Display name chosen by the user.
        public let username: String
        /// Ed25519 signing public key (32 bytes).
        public let signingKeyPublic: Data
        /// X25519 DH public key (32 bytes).
        public let dhKeyPublic: Data
        /// Human-readable safety number (fingerprint) for out-of-band verification.
        public let safetyNumber: String

        public var signingKey: Curve25519.Signing.PublicKey? {
            try? Curve25519.Signing.PublicKey(rawRepresentation: signingKeyPublic)
        }

        public var dhKey: Curve25519.KeyAgreement.PublicKey? {
            try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: dhKeyPublic)
        }

        /// Full 256-bit SHA256 hash of signingKeyPublic || dhKeyPublic.
        /// Used as the Kademlia DHT node ID for XOR-distance routing.
        /// The first 16 hex characters equal `peerID`.
        public var dhtNodeID: Data {
            let combined = signingKeyPublic + dhKeyPublic
            return Data(SHA256.hash(data: combined))
        }

        /// A stable, unique peer identifier derived from the identity keys.
        /// Used as the MultipeerConnectivity display name and as Keychain account suffix.
        public var peerID: String {
            dhtNodeID.prefix(8).hexString
        }
    }

    // MARK: - Private state

    private let keychain: KeychainManager
    private var signingPair: SigningKeyPair
    private var dhPair: DHKeyPair
    public private(set) var publicIdentity: PublicIdentity

    // MARK: - Init

    public init(keychain: KeychainManager) throws {
        self.keychain = keychain

        // Load or generate key pairs before assigning to self.
        // Using a static helper so Swift is satisfied that all stored
        // properties are initialised before any instance method is called.
        let (signing, dh) = try IdentityManager.loadOrCreateKeys(keychain: keychain)
        self.signingPair  = SigningKeyPair(privateKey: signing)
        self.dhPair       = DHKeyPair(privateKey: dh)

        let username = (try? keychain.loadUsername()) ?? "Anonymous"
        self.publicIdentity = IdentityManager.buildStaticPublicIdentity(
            username: username,
            signing:  self.signingPair,
            dh:       self.dhPair
        )

        // Post-quantum key (iOS 18+ only). On older OS this is simply nil; the
        // classical X3DH path runs unchanged.
        #if swift(>=6.2)
        if #available(iOS 19.0, macOS 26.0, *) {
            if let existing = keychain.loadPQIdentityKey() {
                self.pqKeyData = existing
            } else if let pqKey = try? MLKEM768.PrivateKey() {
                let rep = pqKey.integrityCheckedRepresentation
                try? keychain.savePQIdentityKey(rep)
                self.pqKeyData = rep
            }
        }
        #endif
    }

    /// Loads existing identity keys from the Keychain, or generates and saves
    /// new ones on first launch. Throws on Keychain write errors.
    private static func loadOrCreateKeys(
        keychain: KeychainManager
    ) throws -> (signing: Curve25519.Signing.PrivateKey,
                 dh: Curve25519.KeyAgreement.PrivateKey) {
        if let s = try? keychain.loadSigningKey(),
           let d = try? keychain.loadDHIdentityKey() {
            return (s, d)
        }
        // First launch — generate identity key pairs
        let signing = Curve25519.Signing.PrivateKey()
        let dh      = Curve25519.KeyAgreement.PrivateKey()
        try keychain.saveSigningKey(signing)
        try keychain.saveDHIdentityKey(dh)
        return (signing, dh)
    }

    // MARK: - Public API

    // MARK: - Avatar

    public func setAvatar(_ data: Data) throws {
        try keychain.saveAvatar(data)
    }

    public func loadAvatar() -> Data? {
        keychain.loadAvatar()
    }

    public func deleteAvatar() {
        keychain.deleteAvatar()
    }

    public func setUsername(_ username: String) throws {
        let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, trimmed.count <= 64 else {
            throw SophaxError.invalidMessageFormat("Username must be 1–64 characters")
        }
        try keychain.saveUsername(trimmed)
        publicIdentity = IdentityManager.buildStaticPublicIdentity(
            username: trimmed, signing: signingPair, dh: dhPair
        )
    }

    public var signingKeyPair: SigningKeyPair { signingPair }
    public var dhKeyPair: DHKeyPair { dhPair }

    /// Raw 32-byte Ed25519 private key — used only by IdentityExportManager for backup.
    public func signingPrivateKeyData() throws -> Data {
        signingPair.privateKey.rawRepresentation
    }

    /// Raw 32-byte X25519 DH private key — used only by IdentityExportManager for backup.
    public func dhPrivateKeyData() throws -> Data {
        dhPair.privateKey.rawRepresentation
    }

    /// SHA256 hex fingerprint of the identity keys — used in backup to detect cross-identity restores.
    public var identityFingerprint: String {
        let combined = publicIdentity.signingKeyPublic + publicIdentity.dhKeyPublic
        return Data(SHA256.hash(data: combined)).hexString
    }

    /// The Tor v3 .onion hostname derived from the identity's Ed25519 signing key.
    /// Permanent and deterministic — equal to `<peerID>.onion` in structure but
    /// computed via the Tor spec (SHA3-256 checksum + base32). Returns nil only if
    /// the key is somehow not 32 bytes, which cannot happen in practice.
    public var onionHostname: String? {
        try? OnionAddress.from(ed25519PublicKey: publicIdentity.signingKeyPublic)
    }

    /// Sign arbitrary data with the identity signing key.
    public func sign(_ data: Data) throws -> Data {
        let sig = try signingPair.privateKey.signature(for: data)
        return sig
    }

    /// Verify a signature made by a given signing public key.
    public static func verify(
        signature: Data,
        for data: Data,
        signingKeyPublic: Data
    ) throws -> Bool {
        let key = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyPublic)
        return key.isValidSignature(signature, for: data)
    }

    // MARK: - Private helpers

    private static func buildStaticPublicIdentity(
        username: String,
        signing: SigningKeyPair,
        dh: DHKeyPair
    ) -> PublicIdentity {
        let safetyNumber = generateSafetyNumberStatic(
            signing: signing.publicKeyData, dh: dh.publicKeyData
        )
        return PublicIdentity(
            username: username,
            signingKeyPublic: signing.publicKeyData,
            dhKeyPublic: dh.publicKeyData,
            safetyNumber: safetyNumber
        )
    }

    /// Generates a 60-character safety number split into 12 groups of 5 digits.
    /// Used for out-of-band identity verification (read aloud or compare QR codes).
    private static func generateSafetyNumberStatic(signing: Data, dh: Data) -> String {
        // Domain separator prevents another app that uses the same raw key material from
        // producing the same safety number, and makes the hash input unambiguous.
        let domainSep = Data("SophaxChat_SafetyNumber_v1".utf8)
        let combined  = domainSep + signing + dh
        let hash      = SHA512.hash(data: combined)
        let hashData = Data(hash)
        // SHA512 produces 64 bytes; we consume the first 30.
        precondition(hashData.count >= 30, "SHA512 produced invalid output length")

        var groups: [String] = []
        for i in stride(from: 0, to: 30, by: 5) {
            let chunk = hashData[i..<(i + 5)]
            let value = chunk.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } % 100_000
            groups.append(String(format: "%05d", value))
        }
        return groups.joined(separator: " ")
    }
}

// MARK: - Data hex helper

extension Data {
    var hexString: String {
        map { String(format: "%02x", $0) }.joined()
    }
}