feat(ios): wire wrapReleaseKeyForPartner + MockFirestoreReleaseKey tests for the new Cloud Function
This commit is contained in:
parent
fa8005f25f
commit
ade4667db7
|
|
@ -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`. 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.
|
/// Observes the release-key subdoc written by the partner for us.
|
||||||
public func observeOwnReleaseKey(
|
public func observeOwnReleaseKey(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue