feat(ios): wire wrapReleaseKeyForPartner + MockFirestoreReleaseKey tests for the new Cloud Function

This commit is contained in:
null 2026-06-28 17:19:11 -05:00
parent fa8005f25f
commit ade4667db7
2 changed files with 129 additions and 0 deletions

View File

@ -349,6 +349,47 @@ extension FirestoreService {
try await ref.setData(data, merge: true) 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`. iOSiOS uses the native
/// Path A envelope; iOSAndroid 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. /// Observes the release-key subdoc written by the partner for us.
public func observeOwnReleaseKey( public func observeOwnReleaseKey(
coupleId: String, coupleId: String,

View File

@ -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")
}
}