diff --git a/iphone/Closer/Services/FirestoreService.swift b/iphone/Closer/Services/FirestoreService.swift index 85d0553c..42cb97d8 100644 --- a/iphone/Closer/Services/FirestoreService.swift +++ b/iphone/Closer/Services/FirestoreService.swift @@ -349,6 +349,47 @@ extension FirestoreService { try await ref.setData(data, merge: true) } + /// Wraps a one-time AES-256 answer key for the partner via the server-side + /// `wrapReleaseKeyCallable`. This lets iOS release a sealed-answer key to an + /// Android partner without implementing Tink ECIES locally. + /// + /// The function accepts any Tink-compatible `keybox:v1:` string as well as the + /// iOS-native Path A envelope emitted by `KeyboxCrypto`. iOS→iOS uses the native + /// Path A envelope; iOS→Android goes through the server-translated Tink format. + public func wrapReleaseKeyForPartner( + oneTimeKey: Data, + recipientUserId: String, + aad: String = "closer_release_key" + ) async throws -> Keybox { + guard let userId = Auth.auth().currentUser?.uid else { + throw FirestoreError.notAuthenticated + } + let request: [String: Any] = [ + "recipientUserId": recipientUserId, + "oneTimeKey": oneTimeKey.base64EncodedString(), + "aad": aad + ] + let result = try await functions.httpsCallable("wrapReleaseKeyCallable").call(request) + guard let data = result.data as? [String: Any], + let keyboxString = data["keybox"] as? String, + keyboxString.hasPrefix(KeyboxCrypto.keyboxPrefix) else { + throw FirestoreError.invalidResponse + } + // The server may return a Tink-format keybox (raw base64url) or a structured + // Path A JSON envelope. We normalize to the Keybox struct so downstream code + // only has to deal with one type. For Tink keyboxes the raw ciphertext is the + // entire payload; ephemeralPublicKey and mac are empty because Tink embeds + // them internally. + let ciphertextB64 = data["ciphertext"] as? String ?? String(keyboxString.dropFirst(KeyboxCrypto.keyboxPrefix.count)) + let ephemeralPublicKeyB64 = data["ephemeralPublicKey"] as? String ?? "" + let macB64 = data["mac"] as? String ?? "" + return Keybox( + ephemeralPublicKey: Data(base64Encoded: ephemeralPublicKeyB64) ?? Data(), + ciphertext: Data(base64Encoded: ciphertextB64) ?? Data(), + mac: Data(base64Encoded: macB64) ?? Data() + ) + } + /// Observes the release-key subdoc written by the partner for us. public func observeOwnReleaseKey( coupleId: String, diff --git a/iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift b/iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift new file mode 100644 index 00000000..506cdbc0 --- /dev/null +++ b/iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import Closer + +/// Mock implementation of the release-key callable for testing the iOS request shape. +final class MockFirestoreReleaseKey: FirestoreService { + private let recipientUserId: String + private let keyboxResponse: String + private var capturedRequest: [String: Any]? + + init(recipientUserId: String, keyboxResponse: String) { + self.recipientUserId = recipientUserId + self.keyboxResponse = keyboxResponse + super.init() + } + + override func wrapReleaseKeyForPartner( + oneTimeKey: Data, + recipientUserId: String, + aad: String = "closer_release_key" + ) async throws -> Keybox { + // Capture the request shape exactly as the real implementation would send it. + capturedRequest = [ + "recipientUserId": recipientUserId, + "oneTimeKey": oneTimeKey.base64EncodedString(), + "aad": aad + ] + return Keybox( + ephemeralPublicKey: Data(), + ciphertext: Data(base64Encoded: keyboxResponse) ?? Data(), + mac: Data() + ) + } + + func capturedRequestDictionary() -> [String: Any]? { + capturedRequest + } +} + +final class KeyboxCallableTests: XCTestCase { + private let oneTimeKey = Data((0..<32).map { UInt8($0) }) + private let recipientUserId = "partner-user-123" + + func testWrapReleaseKeyRequestShape() async throws { + let mockKeybox = "dGVzdC1rZXlib3gtY2lwaGVydGV4dC1ibG9i" + let service = MockFirestoreReleaseKey( + recipientUserId: recipientUserId, + keyboxResponse: mockKeybox + ) + + let keybox = try await service.wrapReleaseKeyForPartner( + oneTimeKey: oneTimeKey, + recipientUserId: recipientUserId + ) + + guard let request = service.capturedRequestDictionary() else { + XCTFail("Expected a captured request") + return + } + + XCTAssertEqual(request["recipientUserId"] as? String, recipientUserId) + XCTAssertEqual(request["oneTimeKey"] as? String, oneTimeKey.base64EncodedString()) + XCTAssertEqual(request["aad"] as? String, "closer_release_key") + + // The response should be normalized into a Keybox struct. + XCTAssertTrue(keybox.ciphertext.count > 0) + XCTAssertEqual(keybox.ephemeralPublicKey.count, 0) + XCTAssertEqual(keybox.mac.count, 0) + } + + func testWrapReleaseKeyCustomAAD() async throws { + let service = MockFirestoreReleaseKey( + recipientUserId: recipientUserId, + keyboxResponse: "Y3VzdG9tLWtleWJveC1ibG9i" + ) + + _ = try await service.wrapReleaseKeyForPartner( + oneTimeKey: oneTimeKey, + recipientUserId: recipientUserId, + aad: "custom_aad" + ) + + guard let request = service.capturedRequestDictionary() else { + XCTFail("Expected a captured request") + return + } + XCTAssertEqual(request["aad"] as? String, "custom_aad") + } +}