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:
parent
d404301579
commit
7f17f4c673
|
|
@ -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`.*
|
*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.
|
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`.*
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,87 @@ final class CoupleEncryptionManagerTests: XCTestCase {
|
||||||
XCTAssertThrowsError(try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: "ABC124"))
|
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).
|
/// Known-vector test (iOS self-consistency).
|
||||||
///
|
///
|
||||||
/// Uses the first 10 words of the bundled wordlist and a deterministic salt.
|
/// Uses the first 10 words of the bundled wordlist and a deterministic salt.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue