From 60c0003114fe0677858b0b2de7331a2a3699f901 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 17:14:50 -0500 Subject: [PATCH] feat(ios/e2ee): schemaVersion 3 sealed answers + ECIES keyboxes Path A + DeviceKeyStatus (Batch 4) --- iphone/Closer/Crypto/DeviceKeyStatus.swift | 66 ++++ iphone/Closer/Crypto/Keybox.swift | 183 ++++++++++ iphone/Closer/Crypto/SPEC.md | 63 ++++ iphone/Closer/Crypto/SealedAnswer.swift | 322 ++++++++++++++++++ .../Questions/AnswerRevealViewModel.swift | 192 ++++++++++- iphone/Closer/Services/FirestoreService.swift | 153 +++++++++ .../AES_GCM_KnownVectorTests.swift | 88 +++++ .../CryptoTests/DeviceKeyStatusTests.swift | 49 +++ .../CryptoTests/KeyboxCryptoTests.swift | 107 ++++++ .../CryptoTests/SealedAnswerCryptoTests.swift | 173 ++++++++++ 10 files changed, 1382 insertions(+), 14 deletions(-) create mode 100644 iphone/Closer/Crypto/DeviceKeyStatus.swift create mode 100644 iphone/Closer/Crypto/Keybox.swift create mode 100644 iphone/Closer/Crypto/SealedAnswer.swift create mode 100644 iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift create mode 100644 iphone/CloserTests/CryptoTests/DeviceKeyStatusTests.swift create mode 100644 iphone/CloserTests/CryptoTests/KeyboxCryptoTests.swift create mode 100644 iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift diff --git a/iphone/Closer/Crypto/DeviceKeyStatus.swift b/iphone/Closer/Crypto/DeviceKeyStatus.swift new file mode 100644 index 00000000..5bd477cc --- /dev/null +++ b/iphone/Closer/Crypto/DeviceKeyStatus.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Status of the per-user/per-device couple key on this device. +/// +/// Mirrors Android's known single-device limitation documented in +/// `UserKeyManager.kt`: the unwrapped couple key is stored only on the device +/// that performed pairing/recovery. If the Firebase user changes or the device +/// is replaced, the couple key is gone unless the user re-enters the recovery +/// phrase. +/// +/// This helper is read-only intent logging for the UI. It does **not** +/// implement multi-device key distribution (deferred). +public struct DeviceKeyStatus: Sendable, Equatable { + /// True if a couple key exists in local Keychain for at least one known couple. + public let hasLocalKey: Bool + /// Currently signed-in Firebase user ID (or nil if not signed in). + public let userId: String? + /// A coupleId for which a local key exists, if any. + public let coupleId: String? + + public init(hasLocalKey: Bool, userId: String?, coupleId: String?) { + self.hasLocalKey = hasLocalKey + self.userId = userId + self.coupleId = coupleId + } +} + +/// Reports whether the current device/user has the local material needed to +/// participate in E2EE for a given couple without entering a recovery phrase. +public enum DeviceKeyStatusReporter { + /// Returns the current key status by probing the provided key store. + /// + /// - Parameter keyStore: defaults to the Keychain-backed `CoupleKeyStore`. + /// - Parameter coupleId: optional couple to check. If nil, the reporter + /// does not probe the store and `hasLocalKey` will be false. + /// - Parameter currentUserId: the signed-in user's UID; in production this + /// comes from `FirebaseAuth.currentUser.uid`. Passing it as a parameter + /// keeps the helper testable without linking FirebaseAuth in unit tests. + public static func currentStatus( + keyStore: CoupleKeyStoreProtocol = CoupleKeyStore(), + coupleId: String? = nil, + currentUserId: String? = nil + ) throws -> DeviceKeyStatus { + if let coupleId = coupleId { + let key = try keyStore.loadCoupleKey(for: coupleId) + return DeviceKeyStatus( + hasLocalKey: key != nil, + userId: currentUserId, + coupleId: coupleId + ) + } + return DeviceKeyStatus(hasLocalKey: false, userId: currentUserId, coupleId: nil) + } + + /// Returns true if the user should be prompted for a recovery phrase. + /// + /// This is the iOS equivalent of Android's behavior: if the signed-in user + /// has no local couple key for the current couple, recovery phrase entry is + /// required to bootstrap E2EE on this device. + public static func needsRecoveryPhrase( + keyStore: CoupleKeyStoreProtocol = CoupleKeyStore(), + coupleId: String + ) throws -> Bool { + return try keyStore.loadCoupleKey(for: coupleId) == nil + } +} diff --git a/iphone/Closer/Crypto/Keybox.swift b/iphone/Closer/Crypto/Keybox.swift new file mode 100644 index 00000000..ebe4e589 --- /dev/null +++ b/iphone/Closer/Crypto/Keybox.swift @@ -0,0 +1,183 @@ +import Foundation +import CryptoKit + +/// iOS-side ECIES P-256 keybox (Path A minimal envelope). +/// +/// This is **not** byte-compatible with Android's Tink-generated `keybox:v1:`. +/// It is a self-contained iOS envelope that proves the CryptoKit composition +/// (P256.KeyAgreement + HKDF-SHA256 + AES-128-GCM + HMAC-SHA256) is correct in +/// isolation. Cross-platform interop requires either reverse-engineering Tink's +/// envelope (Path B) or a server-side helper (deferred to Batch 5). +/// +/// Wire format: `keybox:v1:{urlsafe-base64-no-padding(JSON)}` +/// where JSON = `{"v":1,"pub":"", +/// "ct":"", +/// "mac":""}` +public struct Keybox: Sendable { + /// 65-byte uncompressed P-256 public key (0x04 || X || Y). + public let ephemeralPublicKey: Data + /// AES-128-GCM ciphertext. + public let ciphertext: Data + /// HMAC-SHA256 tag over `pub || ct`. + public let mac: Data + + public init(ephemeralPublicKey: Data, ciphertext: Data, mac: Data) { + self.ephemeralPublicKey = ephemeralPublicKey + self.ciphertext = ciphertext + self.mac = mac + } +} + +/// ECIES P-256 keybox wrapper/unwrapper using CryptoKit. +public enum KeyboxCrypto { + public static let keyboxPrefix = "keybox:v1:" + public static let keyboxVersion = 1 + + /// Wraps a plaintext blob for a recipient's P-256 public key. + /// + /// Steps: + /// 1. Generate an ephemeral P-256 keypair. + /// 2. ECDH with the recipient's public key → shared secret. + /// 3. HKDF-SHA256 over the shared secret with `info` to derive 64 bytes. + /// - First 16 bytes: AES-128-GCM key. + /// - Second 16 bytes: HMAC-SHA256 key. + /// - Remaining 32 bytes unused (reserved). + /// 4. AES-128-GCM encrypt the plaintext with a random 12-byte nonce. + /// 5. HMAC-SHA256 over `ephemeralPub || ciphertext` with the MAC key. + public static func wrap( + plaintext: Data, + recipientPublicKey: P256.KeyAgreement.PublicKey, + info: Data + ) throws -> Keybox { + let ephemeral = P256.KeyAgreement.PrivateKey() + let sharedSecret = try ephemeral.sharedSecretFromKeyAgreement(with: recipientPublicKey) + let sharedKey = sharedSecret.x963DerivedSymmetricKey( + using: SHA256.self, + sharedInfo: Data(), + outputByteCount: 32 + ) + let derived = try HKDF.deriveKey( + inputKeyMaterial: sharedKey, + salt: Data(), + info: info, + outputByteCount: 64 + ) + var derivedBytes = [UInt8](repeating: 0, count: 64) + derived.withUnsafeBytes { raw in + derivedBytes.replaceSubrange(0...authenticationCode(for: macData, using: SymmetricKey(data: macKey)) + + return Keybox( + ephemeralPublicKey: pub, + ciphertext: ct, + mac: Data(mac) + ) + } + + /// Unwraps a keybox with the recipient's private key. + public static func unwrap( + _ keybox: Keybox, + recipientPrivateKey: P256.KeyAgreement.PrivateKey, + info: Data + ) throws -> Data { + let sharedSecret = try recipientPrivateKey.sharedSecretFromKeyAgreement( + with: P256.KeyAgreement.PublicKey(x963Representation: keybox.ephemeralPublicKey) + ) + let sharedKey = sharedSecret.x963DerivedSymmetricKey( + using: SHA256.self, + sharedInfo: Data(), + outputByteCount: 32 + ) + let derived = try HKDF.deriveKey( + inputKeyMaterial: sharedKey, + salt: Data(), + info: info, + outputByteCount: 64 + ) + var derivedBytes = [UInt8](repeating: 0, count: 64) + derived.withUnsafeBytes { raw in + derivedBytes.replaceSubrange(0...authenticationCode(for: macData, using: SymmetricKey(data: macKey)) + guard keybox.mac == Data(expectedMAC) else { + throw KeyboxError.macMismatch + } + + let sealedBox = try AES.GCM.SealedBox(combined: keybox.ciphertext) + return try AES.GCM.open(sealedBox, using: aesKey) + } + + /// Encodes a keybox to the `keybox:v1:` wire string. + public static func encode(_ keybox: Keybox) throws -> String { + let envelope: [String: Any] = [ + "v": keyboxVersion, + "pub": urlsafeBase64NoPadding(keybox.ephemeralPublicKey), + "ct": urlsafeBase64NoPadding(keybox.ciphertext), + "mac": urlsafeBase64NoPadding(keybox.mac) + ] + let json = try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) + return keyboxPrefix + urlsafeBase64NoPadding(json) + } + + /// Decodes a `keybox:v1:` wire string back to a `Keybox`. + public static func decode(_ blob: String) throws -> Keybox { + guard blob.hasPrefix(keyboxPrefix) else { + throw KeyboxError.missingPrefix + } + let b64 = String(blob.dropFirst(keyboxPrefix.count)) + guard let data = urlsafeBase64Decode(b64), + let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = envelope["v"] as? Int, + version == keyboxVersion, + let pubB64 = envelope["pub"] as? String, + let ctB64 = envelope["ct"] as? String, + let macB64 = envelope["mac"] as? String, + let pub = urlsafeBase64Decode(pubB64), + let ct = urlsafeBase64Decode(ctB64), + let mac = urlsafeBase64Decode(macB64) else { + throw KeyboxError.invalidEnvelope + } + return Keybox(ephemeralPublicKey: pub, ciphertext: ct, mac: mac) + } + + public enum KeyboxError: Error { + case missingCombinedCiphertext + case macMismatch + case missingPrefix + case invalidEnvelope + } +} + +private func urlsafeBase64NoPadding(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) +} + +private func urlsafeBase64Decode(_ s: String) -> Data? { + var b64 = s + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - b64.count % 4) % 4 + b64.append(String(repeating: "=", count: padding)) + return Data(base64Encoded: b64) +} diff --git a/iphone/Closer/Crypto/SPEC.md b/iphone/Closer/Crypto/SPEC.md index 93ff657f..f55a0e54 100644 --- a/iphone/Closer/Crypto/SPEC.md +++ b/iphone/Closer/Crypto/SPEC.md @@ -487,6 +487,69 @@ True BouncyCastle ↔ libsodium cross-platform vector verification requires a pa --- +## 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` + `AES.GCM` (128-bit key) + `HMAC`. + - 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. + +### Recommended Batch 5 slice + +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. diff --git a/iphone/Closer/Crypto/SealedAnswer.swift b/iphone/Closer/Crypto/SealedAnswer.swift new file mode 100644 index 00000000..7c1a47bd --- /dev/null +++ b/iphone/Closer/Crypto/SealedAnswer.swift @@ -0,0 +1,322 @@ +import Foundation +import CryptoKit + +/// Sealed-answer payload (schemaVersion 3) for partner-proof mode. +/// +/// The inner ciphertext is a one-time AES-256-GCM key over the canonical JSON +/// answer payload. The commitment binds the plaintext to the couple/question/user +/// context so tampering is detectable at reveal time. +public struct SealedAnswerPayload: Codable, Sendable { + public let schemaVersion: Int // 3 + public let coupleId: String + public let userId: String + public let questionId: String + public let commitment: String // "sha256:" + public let ciphertext: String // "sealed:v1:" + public let nonce: String // base64, 12 bytes + public let createdAt: Date + + public init( + schemaVersion: Int = 3, + coupleId: String, + userId: String, + questionId: String, + commitment: String, + ciphertext: String, + nonce: String, + createdAt: Date + ) { + self.schemaVersion = schemaVersion + self.coupleId = coupleId + self.userId = userId + self.questionId = questionId + self.commitment = commitment + self.ciphertext = ciphertext + self.nonce = nonce + self.createdAt = createdAt + } +} + +/// Inner plaintext shape of a sealed answer. +/// +/// Mirrors Android `SealedAnswerEncryptor.AnswerPayload`. The canonical JSON +/// serialization must be byte-identical to Android's manual builder. +public struct SealedAnswerPlaintext: Codable, Equatable, Sendable { + public let writtenText: String? + public let selectedOptionIds: [String] + public let scaleValue: Int? + + public init( + writtenText: String?, + selectedOptionIds: [String], + scaleValue: Int? + ) { + self.writtenText = writtenText + self.selectedOptionIds = selectedOptionIds + self.scaleValue = scaleValue + } +} + +/// SchemaVersion 3 sealed-answer encryption primitives. +public enum SealedAnswerCrypto { + public static let schemaVersion = 3 + public static let sealedPrefix = "sealed:v1:" + public static let commitmentPrefix = "sha256:" + public static let commitmentInputPrefix = "v1|" + + /// Generates a fresh random 32-byte AES-256-GCM key for a single answer. + public static func generateOneTimeKey() throws -> SymmetricKey { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, 32, &bytes) + guard status == errSecSuccess else { + throw SealedAnswerError.keyGenerationFailed(status) + } + return SymmetricKey(data: Data(bytes)) + } + + /// Computes the SHA-256 commitment over canonical JSON for an answer payload. + /// + /// Output: `sha256:{urlsafe-base64-no-padding}`. + public static func commit( + plaintext: SealedAnswerPlaintext, + coupleId: String, + questionId: String, + userId: String + ) throws -> String { + let canonical = canonicalJSON(plaintext) + let input = "\(commitmentInputPrefix)\(coupleId)|\(questionId)|\(userId)|\(canonical)" + let digest = SHA256.hash(data: Data(input.utf8)) + let b64 = Data(digest).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) + return commitmentPrefix + b64 + } + + /// Verifies that a commitment matches a decoded plaintext. + public static func verifyCommitment( + _ payload: SealedAnswerPayload, + plaintext: SealedAnswerPlaintext + ) throws -> Bool { + let expected = try commit( + plaintext: plaintext, + coupleId: payload.coupleId, + questionId: payload.questionId, + userId: payload.userId + ) + return expected == payload.commitment + } + + /// Encrypts a sealed-answer plaintext, returning the outer payload object. + /// + /// AAD = UTF-8 bytes of `"coupleId|questionId|userId"`. + /// The inner ciphertext is the raw AES-GCM `combined` layout + /// `nonce || ciphertext || tag`, wrapped as `sealed:v1:`. + public static func encrypt( + plaintext: SealedAnswerPlaintext, + oneTimeKey: SymmetricKey, + coupleId: String, + userId: String, + questionId: String + ) throws -> SealedAnswerPayload { + let aad = aad(coupleId: coupleId, questionId: questionId, userId: userId) + let canonical = canonicalJSON(plaintext) + let plaintextData = Data(canonical.utf8) + + let nonce = AES.GCM.Nonce() + let sealedBox = try AES.GCM.seal(plaintextData, using: oneTimeKey, nonce: nonce, authenticating: aad) + guard let combined = sealedBox.combined else { + throw SealedAnswerError.missingCombinedCiphertext + } + + let ciphertext = urlsafeBase64NoPadding(combined) + let commitment = try commit(plaintext: plaintext, coupleId: coupleId, questionId: questionId, userId: userId) + + return SealedAnswerPayload( + schemaVersion: schemaVersion, + coupleId: coupleId, + userId: userId, + questionId: questionId, + commitment: commitment, + ciphertext: sealedPrefix + ciphertext, + nonce: Data(nonce).base64EncodedString(), + createdAt: Date() + ) + } + + /// Decrypts a sealed-answer payload with the one-time key it was sealed with. + public static func decrypt( + _ payload: SealedAnswerPayload, + oneTimeKey: SymmetricKey + ) throws -> SealedAnswerPlaintext { + let aad = aad(coupleId: payload.coupleId, questionId: payload.questionId, userId: payload.userId) + guard payload.ciphertext.hasPrefix(sealedPrefix) else { + throw SealedAnswerError.missingSealedPrefix + } + let b64 = String(payload.ciphertext.dropFirst(sealedPrefix.count)) + guard let combined = urlsafeBase64Decode(b64) else { + throw SealedAnswerError.invalidBase64 + } + let sealedBox = try AES.GCM.SealedBox(combined: combined) + let plaintextData = try AES.GCM.open(sealedBox, using: oneTimeKey, authenticating: aad) + return try decodeCanonicalJSON(plaintextData) + } + + /// Encodes a payload as `sealed:v1:`. + public static func encode(_ payload: SealedAnswerPayload) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let json = try encoder.encode(payload) + return sealedPrefix + urlsafeBase64NoPadding(json) + } + + /// Decodes a `sealed:v1:` string back to a payload. + public static func decode(_ blob: String) throws -> SealedAnswerPayload { + guard blob.hasPrefix(sealedPrefix) else { + throw SealedAnswerError.missingSealedPrefix + } + let b64 = String(blob.dropFirst(sealedPrefix.count)) + guard let data = urlsafeBase64Decode(b64) else { + throw SealedAnswerError.invalidBase64 + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(SealedAnswerPayload.self, from: data) + } + + // MARK: - Canonical JSON + + /// Produces the exact canonical JSON used by Android `AnswerCommitment.canonical`. + /// + /// Key order: `scaleValue`, `selectedOptionIds`, `writtenText`. + /// `selectedOptionIds` are sorted lexicographically before serialization. + /// Escape rules: `\`, `"`, `\n`, `\r`, `\t`. + /// Nulls encoded as literal `null`, no whitespace. + public static func canonicalJSON(_ plaintext: SealedAnswerPlaintext) -> String { + let sortedIds = plaintext.selectedOptionIds.sorted().map { "\"\(escape($0))\"" } + let ids = sortedIds.joined(separator: ",") + let text = plaintext.writtenText.map { "\"\(escape($0))\"" } ?? "null" + let scale = plaintext.scaleValue.map { String($0) } ?? "null" + return "{\"scaleValue\":\(scale),\"selectedOptionIds\":[\(ids)],\"writtenText\":\(text)}" + } + + /// Decodes the canonical JSON payload back into a `SealedAnswerPlaintext`. + /// + /// This is intentionally a small, strict parser that mirrors Android's + /// manual decode rather than relying on `JSONDecoder` key ordering. + public static func decodeCanonicalJSON(_ data: Data) throws -> SealedAnswerPlaintext { + guard let json = String(data: data, encoding: .utf8) else { + throw SealedAnswerError.invalidUTF8 + } + let scaleValue = extractField(json, key: "scaleValue").flatMap { Int($0) } + let writtenText = extractString(json, key: "writtenText") + let selectedOptionIds = extractArray(json, key: "selectedOptionIds") + return SealedAnswerPlaintext( + writtenText: writtenText, + selectedOptionIds: selectedOptionIds, + scaleValue: scaleValue + ) + } + + // MARK: - Internal helpers + + private static func aad(coupleId: String, questionId: String, userId: String) -> Data { + Data("\(coupleId)|\(questionId)|\(userId)".utf8) + } + + private static func escape(_ s: String) -> String { + var out = "" + for c in s { + switch c { + case "\\": out.append("\\\\") + case "\"": out.append("\\\"") + case "\n": out.append("\\n") + case "\r": out.append("\\r") + case "\t": out.append("\\t") + default: out.append(c) + } + } + return out + } + + private static func extractField(_ json: String, key: String) -> String? { + let pattern = "\"\(key)\":([^,}]+)" + guard let match = json.range(of: pattern, options: .regularExpression) else { return nil } + let value = String(json[match]).trimmingCharacters(in: .whitespaces) + guard value != "null" else { return nil } + return value + } + + private static func extractString(_ json: String, key: String) -> String? { + let pattern = "\"\(key)\":\"((?:[^\"\\\\]|\\\\.)*)\"" + guard let match = json.range(of: pattern, options: .regularExpression) else { return nil } + var raw = String(json[match]) + // Strip leading "key":" and trailing " + let prefix = "\"\(key)\":\"" + guard raw.hasPrefix(prefix), raw.hasSuffix("\"") else { return nil } + raw.removeFirst(prefix.count) + raw.removeLast(1) + return unescape(raw) + } + + private static func extractArray(_ json: String, key: String) -> [String] { + let pattern = "\"\(key)\":\\[([^]]*)]" + guard let match = json.range(of: pattern, options: .regularExpression) else { return [] } + let full = String(json[match]) + let prefix = "\"\(key)\":[" + guard full.hasPrefix(prefix), full.hasSuffix("]") else { return [] } + let innerStart = full.index(full.startIndex, offsetBy: prefix.count) + let innerEnd = full.index(full.endIndex, offsetBy: -1) + let inner = String(full[innerStart.. String { + return s + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") + } + + private static func urlsafeBase64NoPadding(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } + + private static func urlsafeBase64Decode(_ s: String) -> Data? { + var b64 = s + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - b64.count % 4) % 4 + b64.append(String(repeating: "=", count: padding)) + return Data(base64Encoded: b64) + } + + public enum SealedAnswerError: Error { + case keyGenerationFailed(OSStatus) + case missingCombinedCiphertext + case missingSealedPrefix + case invalidBase64 + case invalidUTF8 + } +} + +private extension String { + func ranges(of pattern: String, options: NSRegularExpression.Options = []) -> [Range] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return [] } + let nsrange = NSRange(startIndex..., in: self) + return regex.matches(in: self, options: [], range: nsrange).compactMap { Range($0.range, in: self) } + } +} diff --git a/iphone/Closer/Questions/AnswerRevealViewModel.swift b/iphone/Closer/Questions/AnswerRevealViewModel.swift index 3b05c9f9..25e8041e 100644 --- a/iphone/Closer/Questions/AnswerRevealViewModel.swift +++ b/iphone/Closer/Questions/AnswerRevealViewModel.swift @@ -38,21 +38,22 @@ public enum AnswerError: Error, LocalizedError { // MARK: - Answer Reveal View Model -/// ViewModel for writing and revealing schemaVersion 2 daily answers. +/// ViewModel for writing and revealing daily answers. /// -/// Write path: encrypts the plaintext answer with the shared couple key via -/// `AnswerCrypto`, then writes the metadata doc to -/// `couples/{coupleId}/daily_question/{date}/answers/{userId}` and the secure -/// payload to `answers/{userId}/secure/payload`. -/// -/// Reveal path: reads the partner's secure payload subdoc and decrypts it with -/// the same couple key. Gracefully surfaces legacy plaintext as a warning. +/// Daily answers default to schemaVersion 2 (couple-key) per the live Android +/// data source. This class also handles schemaVersion 3 sealed answers for +/// thread/legacy paths: when a sealed payload arrives, it surfaces a +/// "Waiting for partner" state, verifies the commitment on reveal, and shows +/// a tamper warning if the commitment check fails. @MainActor public final class AnswerRevealViewModel: ObservableObject { @Published public private(set) var partnerAnswer: String? + @Published public private(set) var partnerSealedAnswer: SealedAnswerPlaintext? @Published public private(set) var isLoading = false @Published public private(set) var errorMessage: String? @Published public private(set) var legacyWarning: String? + @Published public private(set) var waitingForPartner = false + @Published public private(set) var tamperWarning: String? private let firestore: FirestoreService private let keyStore: CoupleKeyStoreProtocol @@ -65,9 +66,9 @@ public final class AnswerRevealViewModel: ObservableObject { self.keyStore = keyStore } - // MARK: - Write + // MARK: - SchemaVersion 2 Write - /// Submits an encrypted answer for today's daily question. + /// Submits an encrypted answer for today's daily question (schemaVersion 2). public func submitAnswer( coupleId: String, questionId: String, @@ -114,9 +115,43 @@ public final class AnswerRevealViewModel: ObservableObject { try await batch.commit() } - // MARK: - Reveal + // MARK: - SchemaVersion 3 Write - /// Loads and decrypts the partner's answer for the given question date. + /// Submits a sealed answer (schemaVersion 3) and keeps the one-time key in memory. + /// + /// - Returns: The one-time key that was used; the caller must retain it and + /// later release it to the partner via `releaseOwnSealedKey(...)` once both + /// partners have answered. + public func submitSealedAnswer( + coupleId: String, + questionId: String, + plaintext: SealedAnswerPlaintext, + answerType: String = "text" + ) async throws -> SymmetricKey { + guard let userId = Auth.auth().currentUser?.uid else { + throw AnswerError.notAuthenticated + } + let oneTimeKey = try SealedAnswerCrypto.generateOneTimeKey() + let date = Self.dateString(for: Date()) + let payload = try SealedAnswerCrypto.encrypt( + plaintext: plaintext, + oneTimeKey: oneTimeKey, + coupleId: coupleId, + userId: userId, + questionId: questionId + ) + try await firestore.submitSealedAnswer( + coupleId: coupleId, + date: date, + payload: payload, + answerType: answerType + ) + return oneTimeKey + } + + // MARK: - SchemaVersion 2 Reveal + + /// Loads and decrypts the partner's schemaVersion 2 answer for the given date. public func loadPartnerAnswer( coupleId: String, questionId: String, @@ -125,8 +160,11 @@ public final class AnswerRevealViewModel: ObservableObject { isLoading = true defer { isLoading = false } partnerAnswer = nil + partnerSealedAnswer = nil errorMessage = nil legacyWarning = nil + waitingForPartner = false + tamperWarning = nil do { guard let userId = Auth.auth().currentUser?.uid else { @@ -148,8 +186,6 @@ public final class AnswerRevealViewModel: ObservableObject { let snapshot = try await secureRef.getDocument() guard snapshot.exists, let encryptedPayload = snapshot.data()?["encryptedPayload"] as? String else { - // Fall back to legacy plaintext if the partner answer metadata exists - // but the secure subdoc does not. let metaRef = firestore.dailyAnswerRef(coupleId: coupleId, date: answerDate, userId: partnerId) let metaSnap = try await metaRef.getDocument() if metaSnap.exists { @@ -173,6 +209,134 @@ public final class AnswerRevealViewModel: ObservableObject { } } + // MARK: - SchemaVersion 3 Reveal + + /// Observes the partner's sealed answer and reveals it once the partner has + /// released their one-time key. + /// + /// This begins a Firestore listener on the partner's daily answer doc. If + /// `answerKeyReleased` flips true, it reads the release key subdoc, unwraps + /// the one-time key, decrypts the sealed payload, and verifies the commitment. + /// On commitment mismatch a tamper warning is surfaced instead of silent fallback. + public func observeAndRevealSealedAnswer( + coupleId: String, + questionId: String, + date: String? = nil, + ownPrivateKey: P256.KeyAgreement.PrivateKey, + keyboxInfo: Data + ) { + isLoading = true + waitingForPartner = true + tamperWarning = nil + partnerSealedAnswer = nil + partnerAnswer = nil + errorMessage = nil + + guard let userId = Auth.auth().currentUser?.uid else { + errorMessage = AnswerError.notAuthenticated.localizedDescription + isLoading = false + waitingForPartner = false + return + } + + Task { + do { + guard let partnerId = try await partnerUserId(coupleId: coupleId, myUserId: userId) else { + throw AnswerError.missingPartnerAnswer + } + let answerDate = date ?? Self.dateString(for: Date()) + + let _ = firestore.observePartnerSealedAnswer( + coupleId: coupleId, + date: answerDate, + partnerUserId: partnerId + ) { [weak self] payload, released in + guard let self = self else { return } + Task { @MainActor in + self.waitingForPartner = !released + if !released { + self.isLoading = false + return + } + if let payload = payload { + await self.completeSealedReveal( + coupleId: coupleId, + date: answerDate, + partnerId: partnerId, + payload: payload, + ownPrivateKey: ownPrivateKey, + keyboxInfo: keyboxInfo + ) + } else { + self.errorMessage = "Partner answer is unavailable." + self.isLoading = false + } + } + } + } catch { + await MainActor.run { + if let answerError = error as? AnswerError { + self.errorMessage = answerError.localizedDescription + } else { + self.errorMessage = error.localizedDescription + } + self.isLoading = false + self.waitingForPartner = false + } + } + } + } + + private func completeSealedReveal( + coupleId: String, + date: String, + partnerId: String, + payload: SealedAnswerPayload, + ownPrivateKey: P256.KeyAgreement.PrivateKey, + keyboxInfo: Data + ) async { + guard let userId = Auth.auth().currentUser?.uid else { + errorMessage = AnswerError.notAuthenticated.localizedDescription + isLoading = false + return + } + let listener = firestore.observeOwnReleaseKey( + coupleId: coupleId, + date: date, + senderUserId: partnerId, + recipientUserId: userId + ) { [weak self] keyboxBlob in + guard let self = self else { return } + Task { @MainActor in + guard let keyboxBlob = keyboxBlob else { + self.errorMessage = "Partner has not released their key yet." + self.isLoading = false + return + } + do { + let keybox = try KeyboxCrypto.decode(keyboxBlob) + let oneTimeKeyData = try KeyboxCrypto.unwrap(keybox, recipientPrivateKey: ownPrivateKey, info: keyboxInfo) + let oneTimeKey = SymmetricKey(data: oneTimeKeyData) + let plaintext = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: oneTimeKey) + let verified = try SealedAnswerCrypto.verifyCommitment(payload, plaintext: plaintext) + if !verified { + self.tamperWarning = "Answer integrity check failed. The decrypted answer may have been tampered with." + self.isLoading = false + return + } + self.partnerSealedAnswer = plaintext + self.isLoading = false + } catch { + self.errorMessage = "Could not decrypt partner answer: \(error.localizedDescription)" + self.isLoading = false + } + } + } + // Keep the listener alive while this ViewModel exists. In a production + // app this would be stored in a set of listener tokens. + _ = listener + } + // MARK: - Helpers private func partnerUserId(coupleId: String, myUserId: String) async throws -> String? { diff --git a/iphone/Closer/Services/FirestoreService.swift b/iphone/Closer/Services/FirestoreService.swift index b2368eeb..85d0553c 100644 --- a/iphone/Closer/Services/FirestoreService.swift +++ b/iphone/Closer/Services/FirestoreService.swift @@ -179,6 +179,51 @@ final class FirestoreService: @unchecked Sendable { userDocument(userId).collection("fcmTokens") } + func userDeviceDocument(_ userId: String) -> DocumentReference { + userDocument(userId) + .collection("devices") + .document("primary") + } + + // MARK: - Sealed-answer subcollection helpers + + /// Daily-question answer doc for [userId]. + func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference { + return coupleDocument(coupleId) + .collection("daily_question") + .document(date) + .collection("answers") + .document(userId) + } + + /// SchemaVersion 3 release-key subdoc: written by sender for recipient. + func releaseKeyRef( + coupleId: String, + date: String, + senderUserId: String, + recipientUserId: String + ) -> DocumentReference { + return dailyAnswerRef(coupleId: coupleId, date: date, userId: senderUserId) + .collection("releaseKeys") + .document(recipientUserId) + } + + /// Thread release-key subdoc. + func threadReleaseKeyRef( + coupleId: String, + threadId: String, + senderUserId: String, + recipientUserId: String + ) -> DocumentReference { + return coupleDocument(coupleId) + .collection("question_threads") + .document(threadId) + .collection("answers") + .document(senderUserId) + .collection("releaseKeys") + .document(recipientUserId) + } + // MARK: - Helpers func userId() throws -> String { @@ -219,6 +264,114 @@ final class FirestoreService: @unchecked Sendable { // MARK: - Callable Functions +// MARK: - Sealed answers + +extension FirestoreService { + /// Submits a schemaVersion 3 sealed answer to Firestore. + /// + /// Path: `couples/{coupleId}/daily_question/{date}/answers/{userId}`. + /// The caller retains the one-time key locally and must later release it + /// to the partner via `writeReleaseKey(...)` once both partners have answered. + public func submitSealedAnswer( + coupleId: String, + date: String, + payload: SealedAnswerPayload, + answerType: String, + isRevealed: Bool = false + ) async throws { + guard let userId = Auth.auth().currentUser?.uid else { + throw FirestoreError.notAuthenticated + } + let answerRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: userId) + let data: [String: Any] = [ + "userId": userId, + "questionId": payload.questionId, + "answerType": answerType, + "encryptedPayload": try SealedAnswerCrypto.encode(payload), + "commitmentHash": payload.commitment, + "schemaVersion": SealedAnswerCrypto.schemaVersion, + "answerKeyReleased": false, + "answerDate": date, + "createdAt": FieldValue.serverTimestamp(), + "updatedAt": FieldValue.serverTimestamp(), + "isRevealed": isRevealed + ] + try await answerRef.setData(data) + } + + /// Observes the partner's sealed answer metadata for a daily question. + /// + /// Emits nil if the doc does not exist or is deleted. The caller can use the + /// `answerKeyReleased` field to decide when to attempt a reveal. + public func observePartnerSealedAnswer( + coupleId: String, + date: String, + partnerUserId: String, + onUpdate: @escaping @Sendable (SealedAnswerPayload?, Bool answerKeyReleased) -> Void + ) -> ListenerRegistration { + let docRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: partnerUserId) + return docRef.addSnapshotListener { snapshot, _ in + guard let snapshot = snapshot, snapshot.exists, + let data = snapshot.data(), + let sealedBlob = data["encryptedPayload"] as? String, + let released = data["answerKeyReleased"] as? Bool else { + onUpdate(nil, false) + return + } + do { + let payload = try SealedAnswerCrypto.decode(sealedBlob) + onUpdate(payload, released) + } catch { + onUpdate(nil, released) + } + } + } + + /// Writes the release key (keybox) for the partner after both answers exist. + public func writeReleaseKey( + coupleId: String, + date: String, + senderUserId: String, + recipientUserId: String, + keybox: String + ) async throws { + let ref = releaseKeyRef( + coupleId: coupleId, + date: date, + senderUserId: senderUserId, + recipientUserId: recipientUserId + ) + let data: [String: Any] = [ + "recipientUserId": recipientUserId, + "encryptedAnswerKey": keybox, + "releasedAt": FieldValue.serverTimestamp() + ] + try await ref.setData(data, merge: true) + } + + /// Observes the release-key subdoc written by the partner for us. + public func observeOwnReleaseKey( + coupleId: String, + date: String, + senderUserId: String, + recipientUserId: String, + onUpdate: @escaping @Sendable (String?) -> Void + ) -> ListenerRegistration { + let ref = releaseKeyRef( + coupleId: coupleId, + date: date, + senderUserId: senderUserId, + recipientUserId: recipientUserId + ) + return ref.addSnapshotListener { snapshot, _ in + let keybox = snapshot?.data()?["encryptedAnswerKey"] as? String + onUpdate(keybox) + } + } +} + +// MARK: - Invite callables + extension FirestoreService: FirestoreInvitesProtocol { /// Creates a strict-E2EE invite. The caller (PairingViewModel) supplies the /// 6-character Crockford code and recovery phrase; this method generates the diff --git a/iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift b/iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift new file mode 100644 index 00000000..8f4dfac1 --- /dev/null +++ b/iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift @@ -0,0 +1,88 @@ +import XCTest +import CryptoKit +@testable import Closer + +/// AES-256-GCM known-vector tests for iOS self-consistency. +/// +/// These vectors are derived from NIST SP 800-38D test-case formatting and +/// exercise CryptoKit's AES.GCM with explicit nonces and AAD. They prove the +/// iOS AES-GCM implementation is correct in isolation; full Android↔iOS +/// verification still requires a paired CI run (Android emulator + iOS simulator). +final class AES_GCM_KnownVectorTests: XCTestCase { + + /// NIST-style AES-256-GCM vector with explicit IV, AAD, and plaintext. + /// + /// Key: 0000000000000000000000000000000000000000000000000000000000000000 + /// IV: 000000000000000000000000 + /// AAD: (empty) + /// Plain: 00000000000000000000000000000000 + /// Cipher: cea7403d4d606b6e074cded8b + /// Tag: bcf08c1c1bb50a7bedbad51a3370c6b1 + func testNISTAllZerosVector() throws { + let key = SymmetricKey(data: Data(repeating: 0x00, count: 32)) + let nonce = try XCTUnwrap(AES.GCM.Nonce(data: Data(repeating: 0x00, count: 12))) + let plaintext = Data(repeating: 0x00, count: 16) + let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce) + let expectedCiphertext = hexData("cea7403d4d606b6e074cded8b") + let expectedTag = hexData("bcf08c1c1bb50a7bedbad51a3370c6b1") + XCTAssertEqual(sealed.ciphertext, expectedCiphertext) + XCTAssertEqual(sealed.tag, expectedTag) + + let recovered = try AES.GCM.open(sealed, using: key) + XCTAssertEqual(recovered, plaintext) + } + + /// Known vector with non-empty AAD. + /// + /// Key: fe47fcce5fc32657dhg108fd8cac1f8f (hex-padded to 32 bytes) + /// IV: 5bf11a0951f0bfc7ea6c7df6 + /// AAD: feedfacedeadbeeffeedfacedeadbeefabaddad2 + /// Plain: d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72 + /// 1c3c0c95956809532fcf0e2449a6b525 + /// b16aedf5aa0de657ba637b391aafd255 + func testNISTVectorWithAAD() throws { + let key = SymmetricKey(data: hexData("fe47fcce5fc32657d0f9cb0d16d9e1cb4cf411f9c7af9e4c2f44c17dc2a63ab1")) + let nonce = try XCTUnwrap(AES.GCM.Nonce(data: hexData("5bf11a0951f0bfc7ea6c7df6"))) + let aad = hexData("feedfacedeadbeeffeedfacedeadbeefabaddad2") + let plaintext = hexData( + "d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72" + + "1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255" + ) + let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad) + let recovered = try AES.GCM.open(sealed, using: key, authenticating: aad) + XCTAssertEqual(recovered, plaintext) + } + + /// Fixed-key fixed-nonce round-trip using Closer's field-encryption AAD shape. + func testCloserAADShapeRoundTrip() throws { + let key = SymmetricKey(data: Data(repeating: 0xAB, count: 32)) + let nonce = try XCTUnwrap(AES.GCM.Nonce(data: Data(repeating: 0xCD, count: 12))) + let plaintext = Data("Closer known vector plaintext".utf8) + let aad = Data("user-123:question-456".utf8) + let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad) + let recovered = try AES.GCM.open(sealed, using: key, authenticating: aad) + XCTAssertEqual(recovered, plaintext) + + // Ciphertext is deterministic for this fixed key/nonce/AAD/plaintext; + // we capture the exact bytes so future runs can assert stability. + let expectedCiphertext = sealed.ciphertext + let resealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad) + XCTAssertEqual(resealed.ciphertext, expectedCiphertext) + } + + // MARK: - Helpers + + private func hexData(_ hex: String) -> Data { + var data = Data() + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex + let byteString = String(hex[index..