Gitfed
bastien-mrq/bt-message / btmessage / Crypto / HybridCrypto.swift
// HybridCrypto.swift
// XWingMLKEM768X25519 (ML-KEM-768 + X25519) — iOS 26+
// KEM encapsulation → HKDF-SHA256 → AES-256-GCM

import Foundation
import CryptoKit

// MARK: - Public Key

public struct HybridPublicKey: Codable, Equatable, Hashable {
    public let rawBytes: Data   // 1216 bytes — XWing public key
    public let peerID: String

    public var encoded: Data { rawBytes }

    public static func from(encoded: Data, peerID: String) -> HybridPublicKey? {
        guard encoded.count == 1216 else { return nil }
        return HybridPublicKey(rawBytes: Data(encoded), peerID: peerID)
    }
}

// MARK: - Private Key

public struct HybridPrivateKey {
    public let xwing: XWingMLKEM768X25519.PrivateKey
}

// MARK: - Encrypted Message (pairwise — KEM-based)

public struct EncryptedMessage: Codable {
    public let kemCiphertext: Data   // XWing encapsulated ciphertext (~1120 bytes)
    public let aesCiphertext: Data   // nonce(12) + ciphertext + tag(16)
}

// MARK: - Crypto Engine

public enum HybridCrypto {

    public static func generateKeyPair(peerID: String) throws -> (publicKey: HybridPublicKey, privateKey: HybridPrivateKey) {
        let priv = try XWingMLKEM768X25519.PrivateKey()
        let pub  = HybridPublicKey(rawBytes: priv.publicKey.rawRepresentation, peerID: peerID)
        return (pub, HybridPrivateKey(xwing: priv))
    }

    // MARK: Pairwise encrypt (for DMs and group key distribution)

    public static func encrypt(message: Data, recipientPublicKey: HybridPublicKey) throws -> EncryptedMessage {
        let recipientPub  = try XWingMLKEM768X25519.PublicKey(rawRepresentation: recipientPublicKey.rawBytes)
        let encapResult   = try recipientPub.encapsulate()

        let symmetricKey  = deriveAESKey(from: encapResult.sharedSecret)

        let nonce  = AES.GCM.Nonce()
        let sealed = try AES.GCM.seal(message, using: symmetricKey, nonce: nonce)

        var aesCT = Data()
        aesCT.append(sealed.nonce.withUnsafeBytes { Data($0) })
        aesCT.append(sealed.ciphertext)
        aesCT.append(sealed.tag)

        return EncryptedMessage(kemCiphertext: encapResult.encapsulated, aesCiphertext: aesCT)
    }

    public static func decrypt(message: EncryptedMessage, recipientPrivateKey: HybridPrivateKey) throws -> Data {
        let sharedSecret = try recipientPrivateKey.xwing.decapsulate(message.kemCiphertext)
        let symmetricKey = deriveAESKey(from: sharedSecret)

        return try aesGCMDecrypt(aesCiphertext: message.aesCiphertext, key: symmetricKey)
    }

    // MARK: Group symmetric encrypt/decrypt

    public static func encryptGroup(plaintext: Data, groupKey: SymmetricKey) throws -> Data {
        let nonce  = AES.GCM.Nonce()
        let sealed = try AES.GCM.seal(plaintext, using: groupKey, nonce: nonce)

        var aesCT = Data()
        aesCT.append(sealed.nonce.withUnsafeBytes { Data($0) })
        aesCT.append(sealed.ciphertext)
        aesCT.append(sealed.tag)
        return aesCT
    }

    public static func decryptGroup(aesCiphertext: Data, groupKey: SymmetricKey) throws -> Data {
        try aesGCMDecrypt(aesCiphertext: aesCiphertext, key: groupKey)
    }

    // MARK: - Internals

    private static func deriveAESKey(from sharedSecret: SymmetricKey) -> SymmetricKey {
        // Extract raw bytes then run HKDF-SHA256
        let raw = sharedSecret.withUnsafeBytes { Data($0) }
        return HKDF<SHA256>.deriveKey(
            inputKeyMaterial: SymmetricKey(data: raw),
            info: Data("btmessage-v1".utf8),
            outputByteCount: 32
        )
    }

    private static func aesGCMDecrypt(aesCiphertext: Data, key: SymmetricKey) throws -> Data {
        guard aesCiphertext.count >= 28 else { throw CryptoError.decryptionFailed }
        let nonceData  = Data(aesCiphertext.prefix(12))
        let tag        = Data(aesCiphertext.suffix(16))
        let ciphertext = Data(aesCiphertext[12..<(aesCiphertext.count - 16)])

        let nonce     = try AES.GCM.Nonce(data: nonceData)
        let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
        return try AES.GCM.open(sealedBox, using: key)
    }
}

// MARK: - Errors

public enum CryptoError: Error, LocalizedError {
    case decryptionFailed
    case invalidKey

    public var errorDescription: String? {
        switch self {
        case .decryptionFailed: return "Decryption failed"
        case .invalidKey:       return "Invalid key"
        }
    }
}