docs(ios/crypto): SPEC §18 Batch 5 status + Argon2id fixture-driven test (skips until paired CI fills TODO_ANDROID_RUN)

This commit is contained in:
null 2026-06-28 17:22:31 -05:00
parent d404301579
commit 7f17f4c673
2 changed files with 118 additions and 0 deletions

View File

@ -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`.*

View File

@ -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..<end], radix: 16) else { return nil }
data.append(byte)
}
return data
}
/// Known-vector test (iOS self-consistency).
///
/// Uses the first 10 words of the bundled wordlist and a deterministic salt.