Closer/iphone/Closer/Crypto/SPEC.md

36 KiB
Raw Blame History

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 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 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.

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 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:

{"scaleValue":<int|null>,"selectedOptionIds":["id1","id2"],"writtenText":"<string|null>"}

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):

{
  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

  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.

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.

15. Batch 3 implementation status

Landed in Batch 3:

  • FirestoreService createInvite / acceptInvite real implementations producing/accepting the strict-E2EE four-field payload.
  • PairingViewModel orchestration (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.
  • AnswerRevealViewModel wired to write/read the answers/{userId}/secure/payload subdoc for schemaVersion 2 reveals.
  • Unit tests: InvitePayloadTests, AnswerCryptoTests, updated CoupleEncryptionManagerTests with 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, and enc:v1: wrapper round-trip all pass on the iOS implementation.
  • InvitePayloadTests: a mock FirestoreInvitesProtocol fake proves the create/accept orchestration recovers the same CoupleKeyMaterial without 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|canonicalJson encoded 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 prefixed sealed:v1:.
    • SealedAnswerCryptoTests.swift covers 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.
  • 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.
  • DeviceKeyStatus.swift — read-only status reporting for the single-device limitation:

    • DeviceKeyStatusReporter.currentStatus(...) and needsRecoveryPhrase(...).
    • 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 + answerKeyReleased flag.
    • writeReleaseKey(...) and observeOwnReleaseKey(...) 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_GCM envelope or add a server-side wrapReleaseKeyCallable that accepts the one-time key + recipient's Tink public key and returns a Tink-format keybox: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.saveAnswer currently 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.

  1. 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.
  2. Implement the iOS read side of that helper if the recipient is also iOS (pure CryptoKit composition remains correct in isolation).
  3. Build the paired CI fixture for Argon2id + AES-GCM + sealed-answer canonical vectors and update placeholder tests with real hashes.
  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.

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

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.

  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.