import Foundation import CryptoKit /// iOS-side ECIES P-256 keybox (Path A minimal envelope). /// /// This is **not** byte-compatible with Android's Tink-generated `keybox:v1:`. /// It is a self-contained iOS envelope that proves the CryptoKit composition /// (P256.KeyAgreement + HKDF-SHA256 + AES-128-GCM + HMAC-SHA256) is correct in /// isolation. Cross-platform interop requires either reverse-engineering Tink's /// envelope (Path B) or a server-side helper (deferred to Batch 5). /// /// Wire format: `keybox:v1:{urlsafe-base64-no-padding(JSON)}` /// where JSON = `{"v":1,"pub":"", /// "ct":"", /// "mac":""}` public struct Keybox: Sendable { /// 65-byte uncompressed P-256 public key (0x04 || X || Y). public let ephemeralPublicKey: Data /// AES-128-GCM ciphertext. public let ciphertext: Data /// HMAC-SHA256 tag over `pub || ct`. public let mac: Data public init(ephemeralPublicKey: Data, ciphertext: Data, mac: Data) { self.ephemeralPublicKey = ephemeralPublicKey self.ciphertext = ciphertext self.mac = mac } } /// ECIES P-256 keybox wrapper/unwrapper using CryptoKit. public enum KeyboxCrypto { public static let keyboxPrefix = "keybox:v1:" public static let keyboxVersion = 1 /// Wraps a plaintext blob for a recipient's P-256 public key. /// /// Steps: /// 1. Generate an ephemeral P-256 keypair. /// 2. ECDH with the recipient's public key → shared secret. /// 3. HKDF-SHA256 over the shared secret with `info` to derive 64 bytes. /// - First 16 bytes: AES-128-GCM key. /// - Second 16 bytes: HMAC-SHA256 key. /// - Remaining 32 bytes unused (reserved). /// 4. AES-128-GCM encrypt the plaintext with a random 12-byte nonce. /// 5. HMAC-SHA256 over `ephemeralPub || ciphertext` with the MAC key. public static func wrap( plaintext: Data, recipientPublicKey: P256.KeyAgreement.PublicKey, info: Data ) throws -> Keybox { let ephemeral = P256.KeyAgreement.PrivateKey() let sharedSecret = try ephemeral.sharedSecretFromKeyAgreement(with: recipientPublicKey) let sharedKey = sharedSecret.x963DerivedSymmetricKey( using: SHA256.self, sharedInfo: Data(), outputByteCount: 32 ) let derived = try HKDF.deriveKey( inputKeyMaterial: sharedKey, salt: Data(), info: info, outputByteCount: 64 ) var derivedBytes = [UInt8](repeating: 0, count: 64) derived.withUnsafeBytes { raw in derivedBytes.replaceSubrange(0...authenticationCode(for: macData, using: SymmetricKey(data: macKey)) return Keybox( ephemeralPublicKey: pub, ciphertext: ct, mac: Data(mac) ) } /// Unwraps a keybox with the recipient's private key. public static func unwrap( _ keybox: Keybox, recipientPrivateKey: P256.KeyAgreement.PrivateKey, info: Data ) throws -> Data { let sharedSecret = try recipientPrivateKey.sharedSecretFromKeyAgreement( with: P256.KeyAgreement.PublicKey(x963Representation: keybox.ephemeralPublicKey) ) let sharedKey = sharedSecret.x963DerivedSymmetricKey( using: SHA256.self, sharedInfo: Data(), outputByteCount: 32 ) let derived = try HKDF.deriveKey( inputKeyMaterial: sharedKey, salt: Data(), info: info, outputByteCount: 64 ) var derivedBytes = [UInt8](repeating: 0, count: 64) derived.withUnsafeBytes { raw in derivedBytes.replaceSubrange(0...authenticationCode(for: macData, using: SymmetricKey(data: macKey)) guard keybox.mac == Data(expectedMAC) else { throw KeyboxError.macMismatch } let sealedBox = try AES.GCM.SealedBox(combined: keybox.ciphertext) return try AES.GCM.open(sealedBox, using: aesKey) } /// Encodes a keybox to the `keybox:v1:` wire string. public static func encode(_ keybox: Keybox) throws -> String { let envelope: [String: Any] = [ "v": keyboxVersion, "pub": urlsafeBase64NoPadding(keybox.ephemeralPublicKey), "ct": urlsafeBase64NoPadding(keybox.ciphertext), "mac": urlsafeBase64NoPadding(keybox.mac) ] let json = try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) return keyboxPrefix + urlsafeBase64NoPadding(json) } /// Decodes a `keybox:v1:` wire string back to a `Keybox`. public static func decode(_ blob: String) throws -> Keybox { guard blob.hasPrefix(keyboxPrefix) else { throw KeyboxError.missingPrefix } let b64 = String(blob.dropFirst(keyboxPrefix.count)) guard let data = urlsafeBase64Decode(b64), let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], let version = envelope["v"] as? Int, version == keyboxVersion, let pubB64 = envelope["pub"] as? String, let ctB64 = envelope["ct"] as? String, let macB64 = envelope["mac"] as? String, let pub = urlsafeBase64Decode(pubB64), let ct = urlsafeBase64Decode(ctB64), let mac = urlsafeBase64Decode(macB64) else { throw KeyboxError.invalidEnvelope } return Keybox(ephemeralPublicKey: pub, ciphertext: ct, mac: mac) } public enum KeyboxError: Error { case missingCombinedCiphertext case macMismatch case missingPrefix case invalidEnvelope } } private func urlsafeBase64NoPadding(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .trimmingCharacters(in: CharacterSet(charactersIn: "=")) } private func urlsafeBase64Decode(_ s: String) -> Data? { var b64 = s .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let padding = (4 - b64.count % 4) % 4 b64.append(String(repeating: "=", count: padding)) return Data(base64Encoded: b64) }