36 KiB
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:
- Generate a couple-owned symmetric key (AES-256-GCM, used as a Tink-compatible AEAD).
- Wrap that key with an Argon2id-derived KEK from a recovery phrase.
- Unwrap the couple key with the recovery phrase.
- AEAD-encrypt/decrypt payloads using the couple key (Tink-compatible AES-256-GCM, 12-byte nonce, 16-byte tag, AAD-supported).
- Encrypt a schemaVersion 2
/answers/{userId}/secure/{doc}payload (enc:v1:). - Encrypt a schemaVersion 3 sealed-answer payload (
sealed:v1:+sha256:commitment). - 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 word list in RecoveryKeyManager.WORDLIST. A phrase is 10 space-separated lowercase words drawn uniformly from that list.
Key facts:
- List length: 248 words (verified against the live Android source).
- Phrase word count: 10.
- Entropy: 10 × log₂(248) ≈ 79.3 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 isswift-sodium(or a smallerlibsodium.xcframeworkSPM wrapper) because libsodium is audited and itscrypto_pwhash_argon2idwith explicitopslimit=3,memlimit=47104*1024, andalg=ARGON2ID13is 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.
3.3 Wordlist correction note
The recovery-phrase wordlist described in §3.1 was originally documented as 256 words (≈80 bits entropy). The actual Android RecoveryKeyManager.WORDLIST constant contains 248 words, so the true entropy is 10 × log₂(248) ≈ 79.3 bits. iOS bundles the exact 248-word list in wordlist.txt to preserve cross-platform compatibility. Cross-platform recovery remains byte-identical because the list is copied verbatim.
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 asAES.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
coupleIdas 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:
{"scaleValue":<int|null>,"selectedOptionIds":["id1","id2"],"writtenText":"<string|null>"}
Rules for canonical serialization:
- Keys in this order:
scaleValue,selectedOptionIds,writtenText. selectedOptionIdsis sorted lexicographically before serialization.- Strings are escaped for JSON:
\,",\n,\r,\t. - Null values are encoded as the literal
null(no quotes). - No extra whitespace.
- 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)
questionIdfor daily answers is the question ID; for threads it is thethreadId.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:
- P-256 ECDH key agreement.
- HKDF-SHA256 with an empty salt and the context info as info.
- 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
infoand 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 akeybox: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
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyorkSecAttrAccessibleAfterFirstUnlockThisDeviceOnlydepending 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):
{
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:
{ 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:
{
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}):
{
"userId": "<uid>",
"questionId": "<qid>",
"answerType": "text|multiple_choice|scale",
"schemaVersion": 2,
"answerDate": "YYYY-MM-DD",
"createdAt": <timestamp>,
"updatedAt": <timestamp>,
"isRevealed": false
}
Secure subdoc (answers/{userId}/secure/payload):
{
"encryptedPayload": "enc:v1:<base64>"
}
Allowed update fields: isRevealed, updatedAt.
10.2 SchemaVersion 3 (sealed / partner-proof)
Metadata doc (answers/{userId}):
{
"userId": "<uid>",
"questionId": "<qid>",
"answerType": "text|multiple_choice|scale",
"encryptedPayload": "sealed:v1:<urlsafe-base64-no-padding>",
"commitmentHash": "sha256:<urlsafe-base64-no-padding>",
"schemaVersion": 3,
"answerKeyReleased": false,
"answerDate": "YYYY-MM-DD",
"createdAt": <timestamp>,
"updatedAt": <timestamp>,
"isRevealed": false
}
Allowed update fields: isRevealed, answerKeyReleased, updatedAt.
Release key subdoc (answers/{userId}/releaseKeys/{recipientId}):
{
"recipientUserId": "<recipientId>",
"encryptedAnswerKey": "keybox:v1:<urlsafe-base64-no-padding>",
"releasedAt": <timestamp>
}
10.3 Thread sealed answers
Same as schemaVersion 3 but no answerDate and no isRevealed:
{
"userId": "<uid>",
"questionId": "<qid>",
"answerType": "text|multiple_choice|scale",
"encryptedPayload": "sealed:v1:<...>",
"commitmentHash": "sha256:<...>",
"schemaVersion": 3,
"answerKeyReleased": false,
"createdAt": <timestamp>,
"updatedAt": <timestamp>
}
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 248-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
- Argon2id dependency approval: Is
swift-sodium(libsodium) acceptable, or do you prefer a smaller custom Argon2 SPM wrapper? The latter is riskier. - 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?
- 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?
- 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?
- Keychain accessibility: Should keychain items be
WhenUnlockedThisDeviceOnlyorAfterFirstUnlockThisDeviceOnly? Background FCM decryption may need the latter. - 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:
- Add Argon2id dependency and verify byte compatibility with Android.
- Implement
RecoveryKeyManager.swift(phrase generation from the shared wordlist). - Implement AES-256-GCM
FieldEncryptor.swiftwithenc:v1:wire format. - Implement couple-key wrap/unwrap (
CoupleEncryptionManager.swift/CoupleKeyStore.swift) using Keychain. - Update
FirestoreService.createInviteCallableto generate and send all four E2EE fields. - Update
FirestoreService.acceptInviteCallablepath to decrypt the phrase and unwrap the key. - 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.
15. Batch 3 implementation status
Landed in Batch 3:
FirestoreServicecreateInvite/acceptInvitereal implementations producing/accepting the strict-E2EE four-field payload.PairingViewModelorchestration (startCreateInvite,acceptInvite(code:phrase:)) with Crockford 6-character code generation and recovery-phrase display.AnswerCrypto.swift: schemaVersion 2 daily-answer encrypt/decrypt wrapper (enc:v1:) using the shared couple key.AnswerRevealViewModelwired to write/read theanswers/{userId}/secure/payloadsubdoc for schemaVersion 2 reveals.- Unit tests:
InvitePayloadTests,AnswerCryptoTests, updatedCoupleEncryptionManagerTestswith a known-vector (iOS self-consistency) test. - SPEC.md wordlist correction: 248 words, 79.3 bits entropy.
Deferred to Batch 4+:
- SchemaVersion 3 sealed answers / commitments / ECIES keyboxes.
- Per-user ECIES device keys (
UserKeyManager,PendingAnswerKeyStore). - Full BouncyCastle ↔ libsodium cross-platform Argon2id vector verification in CI (requires Android emulator + iOS simulator + shared fixture).
16. Cross-platform verification status
Batch 3 ships with round-trip + iOS-side self-consistency tests:
CoupleEncryptionManagerTests.testKnownVectorUnwrap: a fixed recovery phrase + fixed salt produce a deterministic KEK on iOS; the unwrapped couple-key hash is asserted against a hardcoded iOS-computed value.AnswerCryptoTests: encrypt/decrypt round-trip, AAD mismatch detection, tamper detection, andenc:v1:wrapper round-trip all pass on the iOS implementation.InvitePayloadTests: a mockFirestoreInvitesProtocolfake proves the create/accept orchestration recovers the sameCoupleKeyMaterialwithout touching live Firebase.
True BouncyCastle ↔ libsodium cross-platform vector verification requires a paired CI run (Android emulator + iOS simulator + a shared test fixture file). The iOS code uses libsodium's ARGON2ID13 with opslimit=3, memlimit=47104*1024, matching the Android PARAMS_TAG. We expect byte-identical output, but this has not yet been validated against a live Android computation. Recommendation: add the cross-platform fixture test to CI before merging the invite acceptance path to main.
17. Batch 4 implementation status + open gaps
What landed in Batch 4
-
SealedAnswer.swift— schemaVersion 3 (sealed:v1:+sha256:commitment) primitives:- Manual canonical JSON builder matching Android's fixed key order, sorted
selectedOptionIds, minimal escape set, and literal-null encoding. - Commitment input
v1|coupleId|questionId|userId|canonicalJsonencoded as UTF-8 and hashed with SHA-256. - Inner AES-256-GCM with AAD
"coupleId|questionId|userId"and 12-byte random nonce. - Outer wire format
sealed:v1:{urlsafe-base64-no-padding}(raw AES-GCM combined bytes) and a JSON metadata wrapper also prefixedsealed:v1:. SealedAnswerCryptoTests.swiftcovers round-trip, canonical-JSON byte-stability against a known Android output string, commitment verification, AAD mismatch, ciphertext tamper, commitment tamper, and empty-option/null-text edge cases.
- Manual canonical JSON builder matching Android's fixed key order, sorted
-
Keybox.swift— iOS-side ECIES P-256 keybox (Path A):- Composed from
CryptoKit.P256.KeyAgreement+HKDF<SHA256>+AES.GCM(128-bit key) +HMAC<SHA256>. - Minimal JSON envelope
{v, pub, ct, mac}with URL-safe base64-no-padding. - Round-trip, AAD mismatch, MAC tamper, ciphertext tamper, and info-string mismatch tests in
KeyboxCryptoTests.swift. - Explicit gap: this envelope is not byte-compatible with Android's Tink-generated
keybox:v1:. iOS↔iOS self-interop works; iOS↔Android keyboxes do not decode across platforms.
- Composed from
-
DeviceKeyStatus.swift— read-only status reporting for the single-device limitation:DeviceKeyStatusReporter.currentStatus(...)andneedsRecoveryPhrase(...).- No multi-device key distribution implemented. Matches Android behavior: a new device for the same user has no couple key until the recovery phrase is entered.
-
FirestoreService.swift— sealed-answer Firestore helpers:submitSealedAnswer(payload:)writes the schemaVersion 3 answer metadata doc.observePartnerSealedAnswer(...)listens for the partner's answer +answerKeyReleasedflag.writeReleaseKey(...)andobserveOwnReleaseKey(...)for the ECIES release-key subdoc path.
-
AnswerRevealViewModel.swift— extended to handle sealed payloads:submitSealedAnswer(...)returns the one-time key for later release.observeAndRevealSealedAnswer(...)surfaces "Waiting for partner" state, verifies the commitment on reveal, and shows a tamper warning (no silent fallback) if the commitment check fails.
-
AES_GCM_KnownVectorTests.swift— NIST-style fixed-key/nonce/AAD vectors and a Closer-AAD-shape deterministic test that captures ciphertext bytes for future regression checks.
Cross-platform status table
| Primitive | iOS self-interop | iOS↔Android | Notes |
|---|---|---|---|
| Argon2id KEK | ✓ (libsodium) | ⏳ Mac/CI needed | opslimit=3, memlimit=47104*1024, ARGON2ID13 |
AES-256-GCM (enc:v1:) |
✓ | ⏳ Mac/CI needed | Same layout, no output prefix |
| Recovery phrase (248 words) | ✓ | ✓ | iOS copies Android wordlist verbatim |
| SchemaVersion 2 daily answers | ✓ | ⏳ Mac/CI needed | Same AAD + wire format |
| SchemaVersion 3 sealed answers | ✓ | ⏳ Mac/CI needed | Canonical JSON + commitment + AAD aligned to Android |
| ECIES keyboxes (Path A) | ✓ | ✗ | Tink envelope mismatch; iOS↔Android does not decode |
What's deferred to Batch 5+
- Multi-device key distribution: the single-device limitation is documented and reproduced, not fixed.
- Keybox Path B / Tink format interop: either reverse-engineer Tink's
ECIES_P256_HKDF_HMAC_SHA256_AES128_GCMenvelope or add a server-sidewrapReleaseKeyCallablethat accepts the one-time key + recipient's Tink public key and returns a Tink-formatkeybox:v1:. The server-side helper is the recommended, lower-risk path. - Full Android↔iOS cross-platform vector verification for Argon2id, AES-GCM, and sealed answers in CI.
- Daily-question default: Android
FirestoreAnswerDataSource.saveAnswercurrently writes schemaVersion 2 daily answers. The sealed path is tested on iOS but not wired as the daily default; this is consistent with the live Android behavior and can be promoted later if product wants partner-proof daily answers.
Canonical-JSON byte-stability verification
The most likely silent break is the canonical JSON contract. SealedAnswerCryptoTests.testCanonicalJSONByteStability asserts a fixed input produces the exact string Android's AnswerCommitment.canonical() would emit. If this test fails, the commitment hash will diverge cross-platform.
Recommended Batch 5 slice
- Add a Cloud Function
wrapReleaseKeyCallable(or equivalent) so iOS can release its one-time answer key to an Android partner without implementing Tink ECIES locally. - Implement the iOS read side of that helper if the recipient is also iOS (pure CryptoKit composition remains correct in isolation).
- Build the paired CI fixture for Argon2id + AES-GCM + sealed-answer canonical vectors and update placeholder tests with real hashes.
- Decide whether daily answers should default to schemaVersion 2 or schemaVersion 3; if schemaVersion 3, wire
PendingAnswerKeyStorepersistence and the full two-sided release flow inAnswerRevealViewModel.
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.
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_RUNplaceholders in:iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.jsoniphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json
wrapReleaseKeyCallableis deployed to the staging Firebase project.wrapReleaseKeyCallableis 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.ktscontains theandroidTestImplementationdeps required forconnectedAndroidTest.- Schema-version promotion decision is recorded in
SCHEMA_VERSION_DECISION.mdand 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:
- Delete the Cloud Function
wrapReleaseKeyCallableviafirebase functions:delete wrapReleaseKeyCallableif it is causing errors. - iOS falls back to schemaVersion 2 by rolling back calls to
FirestoreService.wrapReleaseKeyForPartnerin the daily-answer path. SchemaVersion 2 answers already decrypt end-to-end today. - No data migration is required:
schemaVersionis 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
What landed in Batch 5
AnswerCrypto.swiftAAD fix: schemaVersion 2 daily-answer AAD changed from"{userId}:{questionId}"to the Android-matchingcoupleIdUTF-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-compatiblekeybox:v1:envelope that Android can decrypt. It is exported fromfunctions/src/index.tsbut not yet deployed.FirestoreService.wrapReleaseKeyForPartner(oneTimeKey:recipientUserId:): iOS caller that invokes the new callable and normalizes the response into aKeyboxstruct. 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.swiftfixture-driven tests that skip with a clear message until CI fills theTODO_ANDROID_RUNplaceholders.CoupleEncryptionManagerTests.swifttestArgon2idKnownVectorthat 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:
- Push the same reference inputs to both platforms.
- Capture Android's canonical JSON, commitment SHA-256, and Argon2id output.
- Capture iOS's corresponding outputs.
- 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
- Create
scripts/capture_android_canonical_vectors.shand run it against a paired Android emulator + iOS simulator to fill the sealed-answer and Argon2id fixtures. - Replace placeholder cross-platform tests with real known-vector assertions once fixtures are populated.
- Deploy
wrapReleaseKeyCallableviafirebase deploy --only functions:wrapReleaseKeyCallableand verify iOS→Android release-key end-to-end. - Decide whether daily answers should default to schemaVersion 2 or schemaVersion 3; if schemaVersion 3, wire
PendingAnswerKeyStorepersistence and the full two-sided release flow inAnswerRevealViewModel.
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.