diff --git a/iphone/Closer/Crypto/SPEC.md b/iphone/Closer/Crypto/SPEC.md index f55a0e54..5c65bba3 100644 --- a/iphone/Closer/Crypto/SPEC.md +++ b/iphone/Closer/Crypto/SPEC.md @@ -553,3 +553,40 @@ The most likely silent break is the canonical JSON contract. `SealedAnswerCrypto *Spec written by Neo (subagent) — 2026-06-28. Source material: `docs/Engineering_Reference_Manual.md`, Android `crypto/` package, `functions/src/couples/acceptInviteCallable.ts`, `functions/src/couples/createInviteCallable.ts`, `firestore.rules`.* Correction 2026-06-28: actual Android wordlist size is 248, not 256. Bundled resource reflects the live Android source. Cross-platform recovery remains byte-identical because we copy the list verbatim. + +--- + +## 18. Batch 5 implementation status + cross-platform fixture workflow + +### What landed in Batch 5 + +- **`AnswerCrypto.swift` AAD fix**: schemaVersion 2 daily-answer AAD changed from `"{userId}:{questionId}"` to the Android-matching `coupleId` UTF-8 bytes. This unblocks iOS↔Android daily-answer decryption. +- **`functions/src/releaseKey/wrapReleaseKeyCallable.ts`**: new Cloud Function that wraps an iOS one-time AES-256 key with the recipient's Tink public key and returns a Tink-compatible `keybox:v1:` envelope that Android can decrypt. It is exported from `functions/src/index.ts` but not yet deployed. +- **`FirestoreService.wrapReleaseKeyForPartner(oneTimeKey:recipientUserId:)`**: iOS caller that invokes the new callable and normalizes the response into a `Keybox` struct. This makes iOS accept any Tink-compatible keybox as a Path A envelope, while preserving the native Path A envelope for iOS→iOS. +- **Cross-platform vector harness**: + - `iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json` — canonical JSON + commitment placeholders for sealed answers. + - `iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json` — Argon2id output placeholder. + - `SealedAnswerCryptoTests.swift` fixture-driven tests that skip with a clear message until CI fills the `TODO_ANDROID_RUN` placeholders. + - `CoupleEncryptionManagerTests.swift` `testArgon2idKnownVector` that loads the Argon2id fixture and asserts libsodium output matches once filled. + +### Fixture-filling workflow + +These fixtures are filled by running `scripts/capture_android_canonical_vectors.sh` (not yet written — to be created in Batch 6 if needed) against a connected Android emulator + iOS simulator sharing a reference input. The script will: + +1. Push the same reference inputs to both platforms. +2. Capture Android's canonical JSON, commitment SHA-256, and Argon2id output. +3. Capture iOS's corresponding outputs. +4. Assert they match and write the resulting digests back into the fixture JSON files. + +Until filled, the corresponding iOS tests are skipped or expected to fail with a clear `TODO_ANDROID_RUN` message. + +### Recommended Batch 6 slice + +1. Create `scripts/capture_android_canonical_vectors.sh` and run it against a paired Android emulator + iOS simulator to fill the sealed-answer and Argon2id fixtures. +2. Replace placeholder cross-platform tests with real known-vector assertions once fixtures are populated. +3. Deploy `wrapReleaseKeyCallable` via `firebase deploy --only functions:wrapReleaseKeyCallable` and verify iOS→Android release-key end-to-end. +4. Decide whether daily answers should default to schemaVersion 2 or schemaVersion 3; if schemaVersion 3, wire `PendingAnswerKeyStore` persistence and the full two-sided release flow in `AnswerRevealViewModel`. + +--- + +*Spec written by Neo (subagent) — 2026-06-28. Source material: `docs/Engineering_Reference_Manual.md`, Android `crypto/` package, `functions/src/couples/acceptInviteCallable.ts`, `functions/src/couples/createInviteCallable.ts`, `firestore.rules`.* diff --git a/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift b/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift index 5f097702..9ecd5722 100644 --- a/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift +++ b/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift @@ -38,6 +38,87 @@ final class CoupleEncryptionManagerTests: XCTestCase { XCTAssertThrowsError(try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: "ABC124")) } + /// Loads the Argon2id canonical fixture and asserts libsodium output matches. + /// + /// The expected value is a TODO_ANDROID_RUN placeholder until a paired CI run fills it. + func testArgon2idKnownVector() throws { + let fixture = try loadArgon2idFixture() + + guard let salt = dataFromHex(fixture.saltHex) else { + XCTFail("Invalid salt_hex in fixture") + return + } + + let kek = try CoupleEncryptionManager.unwrapKEK(phrase: fixture.passwordUTF8, salt: salt) + let kekHex = kek.bytes.map { String(format: "%02x", $0) }.joined() + + if fixture.expectedOutputHex == "TODO_ANDROID_RUN" { + XCTSkip("Argon2id fixture needs Android BouncyCastle output") + } else { + XCTAssertEqual(kekHex, fixture.expectedOutputHex, "Argon2id known vector mismatch") + } + } + + // MARK: - Fixture helpers + + private struct Argon2idFixture: Decodable { + let name: String + let passwordUTF8: String + let saltHex: String + let params: Argon2idParams + let expectedOutputHex: String + + enum CodingKeys: String, CodingKey { + case name + case passwordUTF8 = "password_utf8" + case saltHex = "salt_hex" + case params + case expectedOutputHex = "expected_output_hex" + } + } + + private struct Argon2idParams: Decodable { + let mKib: Int + let t: Int + let p: Int + let version: Int + + enum CodingKeys: String, CodingKey { + case mKib = "m_kib" + case t + case p + case version + } + } + + private func loadArgon2idFixture() throws -> Argon2idFixture { + let bundle = Bundle(for: type(of: self)) + guard let url = bundle.url( + forResource: "argon2id_canonical_fixtures", + withExtension: "json" + ) else { + throw XCTSkip("argon2id_canonical_fixtures.json not found in test bundle") + } + let data = try Data(contentsOf: url) + let fixtures = try JSONDecoder().decode([Argon2idFixture].self, from: data) + guard let first = fixtures.first else { + throw XCTSkip("No Argon2id fixtures found") + } + return first + } + + private func dataFromHex(_ hex: String) -> Data? { + guard hex.count % 2 == 0 else { return nil } + var data = Data(capacity: hex.count / 2) + for i in stride(from: 0, to: hex.count, by: 2) { + let start = hex.index(hex.startIndex, offsetBy: i) + let end = hex.index(start, offsetBy: 2) + guard let byte = UInt8(hex[start..