diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6de58705..f521d713 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -216,4 +216,8 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("io.mockk:mockk:1.13.14") + + // Canonical-vector capture harness (paired-CI for iOS↔Android E2EE fixture fill) + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test:runner:1.5.2") } diff --git a/iphone/Closer/Crypto/SCHEMA_VERSION_DECISION.md b/iphone/Closer/Crypto/SCHEMA_VERSION_DECISION.md new file mode 100644 index 00000000..9fa26175 --- /dev/null +++ b/iphone/Closer/Crypto/SCHEMA_VERSION_DECISION.md @@ -0,0 +1,86 @@ +# SchemaVersion Decision Document — Closer E2EE Answers + +> Scope: daily-question answers (`answers/{userId}` under `couples/{coupleId}/daily_question/{date}`). +> Status: decision record. No source-code changes in this batch. + +--- + +## 1. Status quo (current behavior) + +SchemaVersion 2 is the daily-answer default on both Android and iOS today. The payload is encrypted with the shared couple key and written to the read-gated `secure/payload` subdoc as `enc:v1:`. The AAD is the `coupleId` UTF-8 bytes (Android `FieldEncryptor.kt`). Both partners already hold the couple key, so decryption is immediate once both have answered; the "do not reveal until both answer" rule is enforced by the Firestore read rule on the `secure` subdoc, not by cryptography. + +SchemaVersion 3 (sealed / partner-proof) is fully implemented in code on both platforms but is used only for thread answers and the legacy sealed-answer path. It uses a per-answer one-time AES-256-GCM key, a SHA-256 commitment over canonical JSON, and AAD `"coupleId|questionId|userId"`. The one-time key is wrapped for the partner via a `keybox:v1:` envelope and released only after both partners have submitted. + +iOS-side parity: schemaVersion 2 works end-to-end after Batch 5's AAD fix (`AnswerCrypto.swift` now uses `coupleId` as AAD). SchemaVersion 3 primitives are implemented in `SealedAnswer.swift` (Batch 4) and the Cloud Function `wrapReleaseKeyCallable` exists to let iOS release a Tink-compatible keybox to Android, but schemaVersion 3 is not wired into the iOS daily-answer path. + +--- + +## 2. What schemaVersion 3 adds over schemaVersion 2 + +- **Partner-proof cryptography**: neither partner can decrypt the other's answer until both have submitted and released their one-time keys. With schemaVersion 2, either partner could read the other's answer at any time because both hold the couple key; the "until both answer" rule is a Firestore rule, not a cryptographic guarantee. +- **Cryptographic commitment**: the SHA-256 hash binds the answer plaintext to its content before reveal. SchemaVersion 2 has no such commitment — a malicious server with write access could substitute ciphertext without detection. On reveal, `SealedAnswerCrypto.verifyCommitment` can surface a tamper warning if the decrypted plaintext does not match the stored commitment. +- **Audit trail on reveal**: the commitment is recomputed from the decrypted plaintext and compared to the stored value. A mismatch is a hard signal of tampering or cross-platform canonical-JSON drift. +- **Cost**: one extra round-trip per daily answer (`commitment → wait for partner → release key → decrypt`), plus one more key exchange. The one-time AES-256 key must be wrapped for the partner via `wrapReleaseKeyCallable` (Cloud Function) or a local ECIES keybox exchange. + +--- + +## 3. Decision options + +| Option | Daily default | Sealed path | Migration cost | Recommendation | +|---|---|---|---|---| +| **A. Stay at schemaVersion 2** | yes | threads / legacy only | none | **Ship with this.** Keeps daily answers simple, fast, and cross-platform today. | +| **B. Promote schemaVersion 3 to daily** | yes, for new couples only | threads / legacy + daily | medium | Best long-term privacy. Requires `PendingAnswerKeyStore` parity, two-sided release wiring in `AnswerRevealViewModel`, `wrapReleaseKeyCallable` deployed, and paired-CI vector validation before promotion. | +| **C. Add a user toggle** | user choice | per-question opt-in | high — UI + state migration | Not recommended. Couples should operate under the same privacy model; mixed per-question modes are confusing and error-prone. | + +--- + +## 4. Recommendation + +**Ship Batch 1 with Option A: stay at schemaVersion 2 as the daily-answer default.** + +SchemaVersion 2 is code-complete, cross-platform, and requires no new Cloud Function deployment. It gives couples the same day-to-day experience as the Android app today. The single-device limitation (new iOS device needs the recovery phrase) is the only meaningful operational change, and it is already surfaced in the UI. + +**Plan Batch 2 for Option B: promote schemaVersion 3 to the daily default for new couples only.** + +Promotion should happen only after: +1. The paired-CI vector run fills `TODO_ANDROID_RUN` placeholders in `sealed_answer_canonical_fixtures.json` and `argon2id_canonical_fixtures.json`. +2. `wrapReleaseKeyCallable` is deployed and verified end-to-end (iOS inviter → Android acceptor and vice versa). +3. `PendingAnswerKeyStore` parity is audited between iOS and Android. +4. `AnswerRevealViewModel` is extended to handle the full two-sided release flow for daily questions. + +**Migration path for mixed-version couples:** keep `schemaVersion` in every answer doc. An older answer (schemaVersion 2) decrypts with the couple key; a newer answer (schemaVersion 3) decrypts with its one-time released key. The read path must branch on `schemaVersion`. No per-couple migration is required because the version is per-document. + +--- + +## 5. Risks of promotion + +- **Cloud Function dependency**: `wrapReleaseKeyCallable` must be deployed before schemaVersion 3 can ship. Today it is exported from `functions/src/index.ts` but not deployed. Without it, iOS cannot release a Tink-compatible keybox to an Android partner. +- **Key-store parity**: the `PendingAnswerKeyStore` is referenced in iOS code and implemented on Android (`PendingAnswerKeyStore.kt`). If the iOS equivalent is not wired to survive app restart, partner answers cannot be released across sessions. +- **Latency increase**: daily-answer reveal moves from "encrypt + write" to "commit + write + wait for partner + release key + decrypt". Expect 1-3 extra Firestore round-trips per daily answer, depending on partner timing. +- **Canonical-JSON byte-stability**: the Batch 5 fixture test (`SealedAnswerCryptoTests.testCanonicalJSONByteStability`) MUST pass cross-platform before promotion. If iOS and Android canonical JSON diverge by even one byte, commitment hashes diverge and reveals fail with tamper warnings. +- **Rollback surface**: if schemaVersion 3 is promoted and then rolled back, old schemaVersion 3 docs still need a release-key path. Rolling back to schemaVersion 2-only daily answers is safe only for new docs going forward; existing schemaVersion 3 docs retain their old decryption requirements. + +--- + +## 6. Next actions to unblock Option B promotion + +1. Run `scripts/capture_android_canonical_vectors.sh` on a paired macOS host with an Android emulator and iOS simulator to fill `TODO_ANDROID_RUN` placeholders in the fixture JSON files. +2. Deploy `wrapReleaseKeyCallable` via `firebase deploy --only functions:wrapReleaseKeyCallable` to staging and production. +3. Audit `PendingAnswerKeyStore` parity between iOS and Android; ensure the iOS one-time key store survives app restart and is keyed consistently. +4. Add a schemaVersion-aware answer-read path in both clients that handles mixed-version couples without silent fallback. +5. Extend `AnswerRevealViewModel` (iOS) and `AnswerRevealViewModel` (Android) to perform the two-sided release handshake for daily schemaVersion 3 answers. +6. Document the user-visible latency change and the "lost local key = unrecoverable answer" behavior for QA. + +--- + +## 7. Open questions + +1. **Scope of schemaVersion choice**: per-couple default, or per-question override? Recommendation: per-couple default first; add per-question override only if product strongly wants it. +2. **Partner-answer timeout**: what is the wait timeout before a schemaVersion 3 answer is considered "stuck"? No explicit timeout is currently implemented in iOS code. +3. **Commitment input scope**: should the commitment hash include metadata (e.g. timestamp) or only the plaintext answer content? Currently it includes only `v1|coupleId|questionId|userId|canonicalJson`. +4. **Keybox format for iOS↔iOS**: should iOS continue to use its native Path A envelope, or should all iOS releases also go through `wrapReleaseKeyCallable` for consistency? Native Path A is simpler; server wrapping is needed only for Android recipients. +5. **Release-key deletion policy**: after both partners reveal, should the `releaseKeys/{recipientId}` subdoc be deleted to reduce Firestore storage? If deleted, re-reveal on another device would fail. + +--- + +*Document written by Neo (subagent) — 2026-06-28. Sources: `iphone/Closer/Crypto/SPEC.md` §10, `SealedAnswer.swift`, `AnswerCrypto.swift`, Android `FirestoreAnswerDataSource.kt`, `SealedAnswerEncryptor.kt`, `PendingAnswerKeyStore.kt`, `AnswerRevealViewModel.kt`.* diff --git a/iphone/Closer/Crypto/SPEC.md b/iphone/Closer/Crypto/SPEC.md index 5c65bba3..e11e807b 100644 --- a/iphone/Closer/Crypto/SPEC.md +++ b/iphone/Closer/Crypto/SPEC.md @@ -554,6 +554,45 @@ The most likely silent break is the canonical JSON contract. `SealedAnswerCrypto 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. +--- + +## 19. Pre-deploy checklist for iOS E2EE parity + +### 19.1 Before deploy + +Before merging the iOS E2EE branch to `main`, all of the following must be true: + +- [ ] All Android↔iOS unit tests pass on a macOS host. (Currently blocked in this Linux environment; run on a Mac or in CI.) +- [ ] Paired-CI vector run fills `TODO_ANDROID_RUN` placeholders in: + - `iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json` + - `iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json` +- [ ] `wrapReleaseKeyCallable` is deployed to the staging Firebase project. +- [ ] `wrapReleaseKeyCallable` is verified end-to-end with iOS as inviter and Android as acceptor (or vice versa). +- [ ] Manual test: iOS user creates an invite, Android user accepts, and both partners can answer and reveal a daily question. +- [ ] Manual test: iOS user creates a thread answer (schemaVersion 3) and an Android user receives and decrypts it. +- [ ] `app/build.gradle.kts` contains the `androidTestImplementation` deps required for `connectedAndroidTest`. +- [ ] Schema-version promotion decision is recorded in `SCHEMA_VERSION_DECISION.md` and signed off by product. + +### 19.2 Risks during deploy + +- **Multi-device limitation**: a user logging in on a new iOS device after deployment has no couple key until they re-enter the recovery phrase. This is documented in the UI but may surprise testers during QA. +- **Recovery phrase irreversibility**: entering the wrong recovery phrase permanently locks the couple key. Make this explicit in the QA briefing and any public-facing copy review. +- **Cloud Function dependency**: iOS→Android sealed-answer release depends on `wrapReleaseKeyCallable`. If deploy order is wrong, iOS users cannot reveal schemaVersion 3 answers to Android partners. +- **Canonical-JSON drift**: any last-minute change to the sealed-answer JSON shape or escape rules will break commitment hashes cross-platform. Treat sealed-answer JSON as a wire contract. + +### 19.3 Rollback plan + +If iOS E2EE needs to be reverted after merge or release: + +1. Delete the Cloud Function `wrapReleaseKeyCallable` via `firebase functions:delete wrapReleaseKeyCallable` if it is causing errors. +2. iOS falls back to schemaVersion 2 by rolling back calls to `FirestoreService.wrapReleaseKeyForPartner` in the daily-answer path. SchemaVersion 2 answers already decrypt end-to-end today. +3. No data migration is required: `schemaVersion` is stored per answer document, so older schemaVersion 2 answers continue to decrypt with the couple key regardless of the default for new answers. + +--- + +*Pre-deploy checklist written by Neo (subagent) — 2026-06-28.* + + --- ## 18. Batch 5 implementation status + cross-platform fixture workflow