From ae4e6f4542962fd512d1b8cea573fdce1042e38c Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 16:45:19 -0500 Subject: [PATCH] docs(ios/crypto): wire-format spec for CryptoKit interop with Android Tink AEAD + Argon2id (E2EE Batch 1) --- iphone/Closer/Crypto/SPEC.md | 462 +++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 iphone/Closer/Crypto/SPEC.md diff --git a/iphone/Closer/Crypto/SPEC.md b/iphone/Closer/Crypto/SPEC.md new file mode 100644 index 00000000..3b2bd34f --- /dev/null +++ b/iphone/Closer/Crypto/SPEC.md @@ -0,0 +1,462 @@ +# Closer iOS E2EE Interop Specification — Batch 1 + +> Status: research + wire-format spec only. No code implemented in this batch. +> Target: iOS CryptoKit interop with Android Tink AEAD + BouncyCastle Argon2id so that an iOS client can pair end-to-end with an Android client against the existing Cloud Functions. + +--- + +## 1. High-level goal + +Implement, on iOS only, the cryptographic primitives required to: + +1. Generate a couple-owned symmetric key (AES-256-GCM, used as a Tink-compatible AEAD). +2. Wrap that key with an Argon2id-derived KEK from a recovery phrase. +3. Unwrap the couple key with the recovery phrase. +4. AEAD-encrypt/decrypt payloads using the couple key (Tink-compatible AES-256-GCM, 12-byte nonce, 16-byte tag, AAD-supported). +5. Encrypt a schemaVersion 2 `/answers/{userId}/secure/{doc}` payload (`enc:v1:`). +6. Encrypt a schemaVersion 3 sealed-answer payload (`sealed:v1:` + `sha256:` commitment). +7. Encrypt a schemaVersion 3 release-key keybox (`keybox:v1:`) using ECIES P-256. + +This document is the source of truth for the wire-format contracts and the iOS dependency/implementation decisions that will be made in Batch 2. + +--- + +## 2. Encryption versions + +| Version | Name | Live meaning | +|---|---|---| +| 0 | `PLAINTEXT` | Conceptual only. `EncryptionVersion.kt` defines only `STRICT = 2`. No v0 couple can be created today. | +| 1 | `MIGRATING` | Conceptual only. No couple exists at v1. | +| 2 | `STRICT` | **The only creatable version.** All current and new couples are created with `encryptionVersion = 2`. `acceptInviteCallable` and `firestore.rules` hardcode/validate this. | + +Implication for iOS: the iOS client must produce `encryptionVersion = 2` couples and supply the four required E2EE fields (`wrappedCoupleKey`, `kdfSalt`, `kdfParams`, `encryptedRecoveryPhrase`) when calling `createInviteCallable`. There is no plaintext fallback path unless the server and rules are changed together (out of scope for this batch; flag to Ripley if desired). + +--- + +## 3. Recovery phrase + +### 3.1 Wordlist + +The Android wordlist is a hardcoded 256-word list in `RecoveryKeyManager.WORDLIST`. A phrase is **10 space-separated lowercase words** drawn uniformly from that list. + +Key facts: +- List length: 256 words. +- Phrase word count: 10. +- Entropy: 10 × log₂(256) = **80 bits** of raw entropy. +- Encoding: UTF-8, space-separated, no punctuation, lowercase. +- Word separator: single ASCII space `' '` (0x20). + +iOS requirement: use the **exact same wordlist** (copied into iOS as a bundled resource) and the **same 10-word / 256-word uniform generation algorithm**. Even one changed word breaks recovery cross-platform. + +### 3.2 Argon2id KDF parameters + +Used for both: +- Deriving the KEK that wraps the couple keyset from the recovery phrase. +- Deriving the key that encrypts the recovery phrase with the invite code. + +| Parameter | Android value | Notes | +|---|---|---| +| Algorithm | Argon2id | BouncyCastle `Argon2Parameters.ARGON2_id`. | +| Salt length | 16 bytes | Generated with `SecureRandom`. | +| Output length | 32 bytes | Used as an AES-256 key. | +| Memory (`m`) | 46 MiB | `ARGON2_MEMORY_KB = 46 * 1024 = 47104` KiB. | +| Iterations (`t`) | 3 | `ARGON2_ITERATIONS = 3`. | +| Parallelism (`p`) | 1 | `ARGON2_PARALLELISM = 1`. | +| Version | 19 (0x13) | Argon2 v1.3. BouncyCastle uses version byte 19. | +| Param tag | `"argon2id;v=19;m=47104;t=3;p=1"` | Stored in `Couple.kdfParams`. | + +Critical: iOS must produce **byte-identical Argon2id output** for the same password + salt. The most reliable path is to use **libsodium** (via `swift-sodium` or a direct C/SPM wrapper), configured with the same version (1.3/19), `m = 47104` KiB, `t = 3`, `p = 1`, and 32-byte output. + +> ⚠️ CryptoKit has **no Argon2** implementation. A pure-Swift Argon2 implementation exists (`SwiftArgon2`/`Argon2Kit`) but is unaudited and historically bit-rotted. The recommended dependency is `swift-sodium` (or a smaller `libsodium.xcframework` SPM wrapper) because libsodium is audited and its `crypto_pwhash_argon2id` with explicit `opslimit=3`, `memlimit=47104*1024`, and `alg=ARGON2ID13` is designed to match the RFC 9106 / Argon2 v1.3 spec. + +Open question (Batch 2): verify with a known vector that BouncyCastle and libsodium produce identical bytes for the same password/salt. The manual says Android uses `PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"`, which aligns with Argon2 v1.3. + +--- + +## 4. Couple key wrapping (recovery phrase → wrappedCoupleKey) + +### 4.1 Keyset generation + +Android uses Tink `AesGcmKeyManager.aes256GcmTemplate()` to generate a Tink `KeysetHandle` containing one AES-256-GCM key. + +For iOS interop, there are two options: + +**Option A — Native AES-256-GCM key only (recommended for Batch 2).** +- Generate a random 32-byte AES-256 key with `CryptoKit` (`SymmetricKey(size: .bits256)`). +- Treat that 32-byte key as the entire "keyset" on iOS. +- Wrap/unwrap it with Argon2id + AES-256-GCM. +- Do **not** store a Tink JSON envelope locally. + +**Option B — Full Tink JSON keyset envelope translation (future / higher risk).** +- Generate the key as a Tink-compatible cleartext JSON keyset. +- Store the JSON envelope in iOS Keychain. +- This is required only if iOS must read/write Tink's native keyset format. + +Recommendation: **Option A**. The Android `CoupleEncryptionManager.wrap()` serializes the keyset to cleartext JSON and then encrypts that JSON blob. If iOS instead stores a raw 32-byte AES key and wraps it with the same Argon2id+AES-GCM composition, the unwrapped plaintext differs (JSON vs raw key), but the **server never sees the plaintext** — it only stores the base64 ciphertext. The two platforms only need to agree on the wrapped blob, not the internal keyset representation, as long as each platform can recover its own usable AES-256-GCM key from the wrapped blob. + +However, this only works if iOS never needs to read a keyset generated by Android or vice versa from local storage. Local keysets are device-local. The wrapped blob is only used for recovery/pairing, not for day-to-day encryption. Therefore Option A is safe. + +### 4.2 Wrap composition + +``` +wrappedCoupleKey = base64( AES-256-GCM( plaintextKeyMaterial, key=Argon2id(phrase, salt), aad="closer_couple_key" ) ) +kdfSalt = base64( 16 random bytes ) +kdfParams = "argon2id;v=19;m=47104;t=3;p=1" +``` + +- **Plaintext**: for Option A, the raw 32-byte AES key. For Option B, the Tink JSON keyset bytes. +- **AAD**: the literal UTF-8 string `closer_couple_key`. +- **Ciphertext format**: `base64(salt? no — salt is separate)` — Android stores salt separately, so the wrapped blob is **only** the AES-GCM ciphertext (IV + ciphertext + tag). +- The salt is transmitted/stored separately as `kdfSalt`. + +iOS requirement: implement `AES.GCM.seal(..., using: key, nonce: random12ByteNonce, authenticating: aad)` and produce ciphertext that is byte-compatible with Android's `AesGcmJce.encrypt(...)`. + +### 4.3 AES-256-GCM compatibility details + +Both Tink `AesGcmJce` and `CryptoKit.AES.GCM` implement standard NIST SP 800-38D AES-GCM: + +| Property | Value | +|---|---| +| Key size | 256 bits (32 bytes) | +| IV/nonce size | 12 bytes (96 bits) | +| Tag size | 16 bytes (128 bits) | +| Ciphertext layout | `nonce (12) \|\| ciphertext \|\| tag (16)` | + +Android `AesGcmJce` encrypt returns `iv + ciphertext + tag` as a single byte array. Tink's `Aead.encrypt(plaintext, aad)` returns `outputPrefix + iv + ciphertext + tag`; for the `RAW` output prefix (used internally in these paths), the prefix is empty, so the result is exactly `iv + ciphertext + tag`. + +iOS `AES.GCM.seal(...)` returns a `SealedBox` whose `combined` property is `nonce + ciphertext + tag`. This is byte-compatible. + +> ⚠️ `CryptoKit.AES.GCM.seal(plaintext, using: key)` uses a **random nonce** internally when no nonce is supplied. We should explicitly generate 12 random bytes and pass it as `AES.GCM.Nonce(data:)` to guarantee reproducibility and to align with Tink's explicit IV handling. + +--- + +## 5. Field encryption (`enc:v1:`) + +Used for: couple-key encrypted Firestore fields (games, date plans, bucket list, lore, messages, etc.). + +Wire format: `enc:v1:{base64( iv + ciphertext + tag )}` + +- Prefix: `enc:v1:` +- Base64: standard RFC 4648 alphabet, with padding (`={0,2}`). +- AAD: the `coupleId` as UTF-8 bytes. +- Algorithm: AES-256-GCM using the couple key. + +iOS requirement: a `FieldEncryptor` that matches `FieldEncryptor.encrypt/decrypt` exactly. + +--- + +## 6. Sealed answers (schemaVersion 3) + +### 6.1 Wire format + +| Field | Where stored | Format | Regex (from `firestore.rules`) | +|---|---|---|---| +| `encryptedPayload` | `answers/{userId}` or thread messages | `sealed:v1:{urlsafe-base64-no-padding}` | `^sealed:v1:[A-Za-z0-9_-]{80,}$` | +| `commitmentHash` | `answers/{userId}` or thread messages | `sha256:{urlsafe-base64-no-padding}` (43 chars) | `^sha256:[A-Za-z0-9_-]{43}$` | +| `answerKeyReleased` | `answers/{userId}` | boolean | must be `false` on create, may flip to `true` on update | +| `schemaVersion` | `answers/{userId}` | integer | must be `3` | + +The `sealed:v1:` body is the AES-256-GCM ciphertext of the canonical JSON payload, using a one-time key. The one-time key is itself a Tink AES-256-GCM keyset (or, on iOS, a raw 32-byte AES key) generated per answer. + +### 6.2 Canonical payload JSON + +The Android `SealedAnswerEncryptor.encodePayload` builds JSON with this exact shape and key order: + +```json +{"scaleValue":,"selectedOptionIds":["id1","id2"],"writtenText":""} +``` + +Rules for canonical serialization: +1. Keys in this order: `scaleValue`, `selectedOptionIds`, `writtenText`. +2. `selectedOptionIds` is sorted lexicographically before serialization. +3. Strings are escaped for JSON: `\`, `"`, `\n`, `\r`, `\t`. +4. Null values are encoded as the literal `null` (no quotes). +5. No extra whitespace. +6. UTF-8 encoding. + +iOS requirement: produce **byte-identical canonical JSON** for the same inputs. Use a manual JSON builder, not `JSONEncoder`, because `JSONEncoder` does not guarantee key order or the exact null representation in all cases. + +### 6.3 Commitment hash + +Android `AnswerCommitment.compute` computes: + +``` +input = "v1|{coupleId}|{questionId}|{userId}|{canonicalJson}" +hash = SHA-256(input) +output = "sha256:" + base64url-no-padding(hash) +``` + +- `questionId` for daily answers is the question ID; for threads it is the `threadId`. +- `base64url-no-padding` = URL-safe alphabet (`-` and `_`) with no `=` padding. +- Output length: `sha256:` (7) + 43 chars = 50 chars. + +iOS requirement: use `CryptoKit.SHA256` over the exact same UTF-8 input and encode with URL-safe base64 no-padding. + +### 6.4 AAD for sealed encryption + +``` +aad = "{coupleId}|{questionId}|{userId}" (UTF-8 bytes) +``` + +Same pipe-delimited format as the commitment, but **without** the leading `v1|` and without the canonical JSON. + +--- + +## 7. ECIES P-256 keyboxes (`keybox:v1:`) + +### 7.1 Android implementation + +Android uses Tink's `HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM`. + +From the Tink proto specs: +- **KEM**: ECIES over NIST P-256. +- **HKDF hash**: SHA-256. +- **DEM**: AES-128-GCM. +- **Context info**: bound to `coupleId|questionId|senderUserId|recipientUserId`. +- Public key wire format: `pub:v1:{urlsafe-base64-no-padding( public_keyset_JSON )}`. +- Keybox wire format: `keybox:v1:{urlsafe-base64-no-padding( tink_hybrid_ciphertext )}`. + +The public key stored in Firestore is **not** a raw X9.62 point; it is a Tink `EciesAeadHkdfPublicKey` protobuf serialized as a cleartext keyset JSON, then base64url-encoded. + +### 7.2 iOS gap: this is the hardest interop point + +Tink's ECIES construction is **not** raw ECIES. It uses: +1. P-256 ECDH key agreement. +2. HKDF-SHA256 with an empty salt and the context info as info. +3. AES-128-GCM for the DEM (with a Tink-generated random nonce). + +CryptoKit provides `P256.KeyAgreement` and `HKDF`, but there are two risks: +- Tink's HKDF `info` and salt conventions may differ from a naïve CryptoKit composition. +- Tink's DEM is AES-128-GCM, not AES-256-GCM, and its nonce/tag handling is embedded in the Tink ciphertext. +- The public key wire format uses Tink's keyset JSON envelope, not a raw SEC1/X9.62 point. + +Options: + +**Option 1 — Port Tink's ECIES composition in pure Swift/CryptoKit (highest effort, highest risk).** +- Read the Tink Java source for `EciesAeadHkdfDemHelper`, `EciesAeadHkdfHybridEncrypt`, and the proto serialization. +- Reproduce the exact HKDF info/salt and AES-128-GCM envelope. +- Risk: subtle byte differences break interop; extensive test vectors required. + +**Option 2 — Embed a minimal Tink C++/Java bridge via SPM (not available cleanly for Swift 6/iOS 17).** +- Tink does not ship an official Swift/SPM package. There are unofficial ports, but none are clearly maintained for iOS 17+. + +**Option 3 — Server-side helper / mixed-format keybox (recommended short-term).** +- Keep Android's sealed-answer path unchanged. +- Add a Cloud Function (e.g. `wrapReleaseKeyCallable`) that takes the one-time key, recipient's Tink public key, and context info, and returns a `keybox:v1:` string. +- iOS would call this function to release its one-time key, avoiding the need to implement Tink ECIES locally. +- The recipient (Android or iOS) still decrypts with its local private key. If the recipient is iOS, it also needs to decrypt the keybox, which brings us back to needing Tink ECIES decryption. + +**Option 4 — Drop sealed-answer partner-proof for iOS initially; use schemaVersion 2 for everything (recommended for first playable iOS build).** +- Daily answers already default to schemaVersion 2 (`enc:v1:` with the couple key). +- Thread messages and the legacy sealed-answer path would remain Android-only until iOS E2EE is complete. +- This is the lowest-risk path to get iOS pairing + daily questions working. + +Recommendation for Batch 2: **Option 4** for the first slice (schemaVersion 2 only). Document that schemaVersion 3 / sealed answers / keyboxes are Batch 3 or later, and flag to Ripley whether a server-side helper (Option 3) is acceptable for the sealed-answer release path. + +--- + +## 8. Key storage on iOS + +Android uses `EncryptedSharedPreferences` (Keystore-backed) for: +- `CoupleKeyStore` — couple keyset JSON. +- `UserKeyManager` — user's ECIES private keyset JSON. +- `PendingAnswerKeyStore` — one-time answer keys JSON. +- Recovery phrase plaintext. + +iOS replacement: **Keychain Services** (`Security.framework` / `SecItemAdd`/`SecItemCopyMatching`). + +Requirements: +- Store raw key bytes and recovery phrases as generic passwords or keys. +- Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` or `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` depending on whether background decryption is needed. +- Namespace keys by coupleId / userId to support multi-account use cases. +- For the inviter reconciliation path, store the keyset under the invite code first, then migrate to the coupleId after the server confirms the couple (mirrors `CoupleKeyStore.reconcileInviteKeyset`). + +No Cloud backup: mark keychain items with `kSecAttrAccessible...ThisDeviceOnly` so they are not included in iCloud Keychain backup. This matches Android's device-bound Keystore behavior. + +--- + +## 9. Cloud Function contracts + +### 9.1 `createInviteCallable` + +Current required fields (from `functions/src/couples/createInviteCallable.ts`): + +```ts +{ + code: string, // 6-char Crockford alphabet [A-HJ-NP-Z2-9] + wrappedCoupleKey: string, // base64 ciphertext + kdfSalt: string, // base64 salt + kdfParams: string, // "argon2id;v=19;m=47104;t=3;p=1" + encryptedRecoveryPhrase: string // base64( salt[16] || AES-GCM(phrase) ) +} +``` + +Response: +```ts +{ code: string, expiresAt: Timestamp } +``` + +iOS requirement: must generate the recovery phrase, wrap the couple key, encrypt the phrase with the code, and pass all four E2EE fields. The code must match the server regex `^[A-HJ-NP-Z2-9]{6}$`. + +### 9.2 `acceptInviteCallable` + +Request: `{ code: string }` +Response: +```ts +{ + coupleId: string, + inviterUserId: string, + wrappedCoupleKey: string, + kdfSalt: string, + kdfParams: string, + encryptedRecoveryPhrase: string +} +``` + +iOS requirement: after receiving the response, decrypt `encryptedRecoveryPhrase` with the invite code using Argon2id+AES-GCM (AAD = `"closer_invite_phrase"`), then unwrap the couple key with the phrase using Argon2id+AES-GCM (AAD = `"closer_couple_key"`). + +--- + +## 10. Answer document shapes + +### 10.1 SchemaVersion 2 (daily answers — current default) + +Metadata doc (`answers/{userId}`): +```json +{ + "userId": "", + "questionId": "", + "answerType": "text|multiple_choice|scale", + "schemaVersion": 2, + "answerDate": "YYYY-MM-DD", + "createdAt": , + "updatedAt": , + "isRevealed": false +} +``` + +Secure subdoc (`answers/{userId}/secure/payload`): +```json +{ + "encryptedPayload": "enc:v1:" +} +``` + +Allowed update fields: `isRevealed`, `updatedAt`. + +### 10.2 SchemaVersion 3 (sealed / partner-proof) + +Metadata doc (`answers/{userId}`): +```json +{ + "userId": "", + "questionId": "", + "answerType": "text|multiple_choice|scale", + "encryptedPayload": "sealed:v1:", + "commitmentHash": "sha256:", + "schemaVersion": 3, + "answerKeyReleased": false, + "answerDate": "YYYY-MM-DD", + "createdAt": , + "updatedAt": , + "isRevealed": false +} +``` + +Allowed update fields: `isRevealed`, `answerKeyReleased`, `updatedAt`. + +Release key subdoc (`answers/{userId}/releaseKeys/{recipientId}`): +```json +{ + "recipientUserId": "", + "encryptedAnswerKey": "keybox:v1:", + "releasedAt": +} +``` + +### 10.3 Thread sealed answers + +Same as schemaVersion 3 but **no `answerDate` and no `isRevealed`**: +```json +{ + "userId": "", + "questionId": "", + "answerType": "text|multiple_choice|scale", + "encryptedPayload": "sealed:v1:<...>", + "commitmentHash": "sha256:<...>", + "schemaVersion": 3, + "answerKeyReleased": false, + "createdAt": , + "updatedAt": +} +``` + +Allowed update fields: `answerKeyReleased`, `updatedAt`. + +--- + +## 11. iOS dependency recommendations + +### 11.1 New dependencies required + +| Dependency | Purpose | Recommendation | +|---|---|---| +| **Argon2id** | KEK derivation | `swift-sodium` (libsodium wrapper) or a minimal libsodium SPM package. Must test byte output against Android. | +| **Keychain wrapper** | Secure storage | No new dependency needed; use `Security.framework` directly or a small internal helper. | + +### 11.2 Dependencies to avoid + +| Dependency | Why not | +|---|---| +| `SwiftArgon2` / `Argon2Kit` pure-Swift | Unaudited, potential bit-rot, no guarantee of byte compatibility with BouncyCastle. | +| Unofficial Tink Swift ports | Maintenance burden, unclear iOS 17/Swift 6 support, security audit status unknown. | +| BoringSSL-TLC via SPM | Heavy, increases binary size, and still requires reimplementing Tink's ECIES composition. | + +### 11.3 No dependency change yet + +Per Batch 1 instructions, **do not modify `Package.swift`**. The dependency decision is recorded here for Batch 2 approval. + +--- + +## 12. Gap analysis and migration strategy + +| Gap | Android behavior | iOS equivalent? | Migration strategy | +|---|---|---|---| +| **Argon2id KDF** | BouncyCastle Argon2id v1.3, m=47104 KiB, t=3, p=1 | Not in CryptoKit | Add `swift-sodium` or libsodium wrapper; verify cross-platform vectors. | +| **Tink AES-256-GCM keyset envelope** | Key stored as cleartext Tink JSON keyset | CryptoKit `SymmetricKey` | Store raw 32-byte key on iOS; server only sees wrapped ciphertext. Cross-device recovery uses the wrapped blob + phrase, not the raw envelope. | +| **Tink ECIES P-256 hybrid encryption (keyboxes)** | Tink `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` with HKDF-SHA256 + AES-128-GCM DEM | Not directly available in CryptoKit | **Defer to Batch 3**. Short-term use schemaVersion 2 (couple-key) only. Medium-term consider a server-side `wrapReleaseKeyCallable`. | +| **Tink public key wire format (`pub:v1:...`)** | Tink public keyset JSON, base64url-no-padding | No native equivalent | Only needed for schemaVersion 3 sealed answers. Defer. | +| **Recovery phrase wordlist** | Hardcoded 256-word list | Must bundle identical list | Copy list into iOS bundle. No algorithmic change. | +| **Canonical JSON for commitment** | Manual builder with fixed key order/sorting | `JSONEncoder` won't guarantee order | Implement manual JSON builder in Swift. | +| **Keychain vs EncryptedSharedPreferences** | Keystore-backed encrypted prefs | Keychain Services | Implement small wrapper; store device-local. | + +--- + +## 13. Open questions for Ripley / next batch + +1. **Argon2id dependency approval**: Is `swift-sodium` (libsodium) acceptable, or do you prefer a smaller custom Argon2 SPM wrapper? The latter is riskier. +2. **Sealed-answer scope**: Should Batch 2 implement schemaVersion 2 (couple-key daily answers) first and defer schemaVersion 3 / keyboxes, or do you want the sealed-answer path included from the start? +3. **Server-side keybox helper**: If sealed answers are required on iOS, is a Cloud Function that wraps the one-time key acceptable as a short-term bridge, or do we need pure-client CryptoKit ECIES? +4. **Multi-device keys**: Android has a known single-device limitation. Should iOS reproduce the same limitation, or should we design multi-device key distribution now? +5. **Keychain accessibility**: Should keychain items be `WhenUnlockedThisDeviceOnly` or `AfterFirstUnlockThisDeviceOnly`? Background FCM decryption may need the latter. +6. **Tink keyset on iOS**: Confirm that storing a raw 32-byte AES key on iOS (instead of a Tink envelope) is acceptable, given the wrapped blob is the only cross-platform artifact. + +--- + +## 14. Recommended Batch 2 slice + +Based on the gaps above, the smallest coherent Batch 2 implementation is: + +1. Add Argon2id dependency and verify byte compatibility with Android. +2. Implement `RecoveryKeyManager.swift` (phrase generation from the shared wordlist). +3. Implement AES-256-GCM `FieldEncryptor.swift` with `enc:v1:` wire format. +4. Implement couple-key wrap/unwrap (`CoupleEncryptionManager.swift` / `CoupleKeyStore.swift`) using Keychain. +5. Update `FirestoreService.createInviteCallable` to generate and send all four E2EE fields. +6. Update `FirestoreService.acceptInviteCallable` path to decrypt the phrase and unwrap the key. +7. Write unit tests that round-trip encrypt/decrypt between a known Android vector (or mocked vector) and iOS. + +**Deferred to Batch 3+**: schemaVersion 3 sealed answers, commitments, ECIES keyboxes, and user device keys. + +--- + +*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`.*