From 3d3209806cce0be58efe8af72ab1c305dbc78c5c Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 17:19:00 -0500 Subject: [PATCH] fix(ios/crypto): AnswerCrypto AAD to coupleId-only (matches Android FieldEncryptor); add vector fixtures for sealed-answer canonical JSON + Argon2id (TODO_ANDROID_RUN placeholders for paired CI) --- iphone/Closer/Crypto/AnswerCrypto.swift | 10 +-- .../argon2id_canonical_fixtures.json | 15 ++++ .../sealed_answer_canonical_fixtures.json | 27 +++++++ .../Questions/AnswerRevealViewModel.swift | 2 +- .../CryptoTests/AnswerCryptoTests.swift | 72 +++++++++++++++-- .../CryptoTests/SealedAnswerCryptoTests.swift | 81 +++++++++++++++++++ 6 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json create mode 100644 iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json diff --git a/iphone/Closer/Crypto/AnswerCrypto.swift b/iphone/Closer/Crypto/AnswerCrypto.swift index f8e6a243..5db58d03 100644 --- a/iphone/Closer/Crypto/AnswerCrypto.swift +++ b/iphone/Closer/Crypto/AnswerCrypto.swift @@ -47,7 +47,7 @@ public enum AnswerCrypto { coupleId: String, key: CoupleKeyMaterial ) throws -> SecureAnswerPayload { - let aad = answerAAD(userId: userId, questionId: questionId) + let aad = answerAAD(coupleId: coupleId) let ciphertext = try FieldEncryptor.encryptString( answerPlaintext, key: key.rawKey, @@ -64,8 +64,8 @@ public enum AnswerCrypto { } /// Decrypts a schemaVersion 2 secure answer payload. - public static func decrypt(_ payload: SecureAnswerPayload, key: CoupleKeyMaterial) throws -> String { - let aad = answerAAD(userId: payload.userId, questionId: payload.questionId) + public static func decrypt(_ payload: SecureAnswerPayload, coupleId: String, key: CoupleKeyMaterial) throws -> String { + let aad = answerAAD(coupleId: coupleId) return try FieldEncryptor.decryptString( payload.ciphertext, key: key.rawKey, @@ -97,8 +97,8 @@ public enum AnswerCrypto { return try JSONDecoder().decode(SecureAnswerPayload.self, from: data) } - private static func answerAAD(userId: String, questionId: String) -> Data? { - "\(userId):\(questionId)".data(using: .utf8) + private static func answerAAD(coupleId: String) -> Data? { + coupleId.data(using: .utf8) } public enum AnswerCryptoError: Error { diff --git a/iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json b/iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json new file mode 100644 index 00000000..64a0c01a --- /dev/null +++ b/iphone/Closer/Crypto/Resources/argon2id_canonical_fixtures.json @@ -0,0 +1,15 @@ +// Argon2id cross-platform fixture. +// +// The expected_output_hex value must be filled by running Android's +// `RecoveryKeyManager.deriveKEK` (BouncyCastle Argon2id) with the same password and salt. +// Until the next paired CI run fills it, iOS tests that load this fixture skip with a +// clear placeholder message. +[ + { + "name": "fixed_phrase_salt_v1", + "password_utf8": "recovery phrase in cleartext here", + "salt_hex": "000102030405060708090a0b0c0d0e0f", + "params": { "m_kib": 47104, "t": 3, "p": 1, "version": 19 }, + "expected_output_hex": "TODO_ANDROID_RUN" + } +] diff --git a/iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json b/iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json new file mode 100644 index 00000000..93ac6fd3 --- /dev/null +++ b/iphone/Closer/Crypto/Resources/sealed_answer_canonical_fixtures.json @@ -0,0 +1,27 @@ +// This file captures canonical JSON + commitment vectors for sealed answers. +// +// Filling the expected_* values requires running Android's `SealedAnswerEncryptor` +// with the same inputs. The values below are intentionally TODO_ANDROID_RUN placeholders. +// They will be replaced by the next paired CI run that has both an Android emulator and an +// iOS simulator (or a Mac with both build targets available). Until then, the iOS tests +// that read this file are skipped or expected to fail with a clear placeholder message. +[ + { + "name": "minimal_true_false", + "input": { "plaintext": "Yes", "questionId": "q-001", "userId": "u-a", "coupleId": "c-1" }, + "expected_canonical_json": "TODO_ANDROID_RUN", + "expected_commitment_sha256": "TODO_ANDROID_RUN" + }, + { + "name": "scale_answer", + "input": { "plaintext": "7", "questionId": "q-002", "userId": "u-b", "coupleId": "c-1" }, + "expected_canonical_json": "TODO_ANDROID_RUN", + "expected_commitment_sha256": "TODO_ANDROID_RUN" + }, + { + "name": "written_multiline", + "input": { "plaintext": "I love that you...\n...make me laugh", "questionId": "q-003", "userId": "u-a", "coupleId": "c-1" }, + "expected_canonical_json": "TODO_ANDROID_RUN", + "expected_commitment_sha256": "TODO_ANDROID_RUN" + } +] diff --git a/iphone/Closer/Questions/AnswerRevealViewModel.swift b/iphone/Closer/Questions/AnswerRevealViewModel.swift index 25e8041e..2743ef2a 100644 --- a/iphone/Closer/Questions/AnswerRevealViewModel.swift +++ b/iphone/Closer/Questions/AnswerRevealViewModel.swift @@ -198,7 +198,7 @@ public final class AnswerRevealViewModel: ObservableObject { guard decodedPayload.schemaVersion == AnswerCrypto.schemaVersion else { throw AnswerError.unsupportedSchemaVersion(decodedPayload.schemaVersion) } - let plaintext = try AnswerCrypto.decrypt(decodedPayload, key: key) + let plaintext = try AnswerCrypto.decrypt(decodedPayload, coupleId: coupleId, key: key) partnerAnswer = plaintext } catch { if let answerError = error as? AnswerError { diff --git a/iphone/CloserTests/CryptoTests/AnswerCryptoTests.swift b/iphone/CloserTests/CryptoTests/AnswerCryptoTests.swift index 873420dc..6742fc62 100644 --- a/iphone/CloserTests/CryptoTests/AnswerCryptoTests.swift +++ b/iphone/CloserTests/CryptoTests/AnswerCryptoTests.swift @@ -22,7 +22,7 @@ final class AnswerCryptoTests: XCTestCase { XCTAssertEqual(payload.schemaVersion, AnswerCrypto.schemaVersion) XCTAssertTrue(payload.ciphertext.hasPrefix(FieldEncryptor.prefix)) - let recovered = try AnswerCrypto.decrypt(payload, key: key) + let recovered = try AnswerCrypto.decrypt(payload, coupleId: coupleId, key: key) XCTAssertEqual(recovered, plaintext) } @@ -44,10 +44,68 @@ final class AnswerCryptoTests: XCTestCase { XCTAssertEqual(decoded.coupleId, coupleId) XCTAssertEqual(decoded.schemaVersion, AnswerCrypto.schemaVersion) - let recovered = try AnswerCrypto.decrypt(decoded, key: key) + let recovered = try AnswerCrypto.decrypt(decoded, coupleId: coupleId, key: key) XCTAssertEqual(recovered, plaintext) } + func testAADIsCoupleIdOnly() throws { + let plaintext = "AAD contract check" + let payload = try AnswerCrypto.encrypt( + answerPlaintext: plaintext, + userId: userId, + questionId: questionId, + coupleId: coupleId, + key: key + ) + + // Strip the outer enc:v1: JSON wrapper so we can inspect the inner ciphertext. + let innerBlob = try AnswerCrypto.decode(payload.ciphertext) + let innerCiphertext = innerBlob.ciphertext + guard innerCiphertext.hasPrefix(FieldEncryptor.prefix) else { + XCTFail("Inner ciphertext missing enc:v1: prefix") + return + } + let combined = Data(base64Encoded: String(innerCiphertext.dropFirst(FieldEncryptor.prefix.count))) + XCTAssertNotNil(combined) + XCTAssertGreaterThanOrEqual(combined?.count ?? 0, 28) // nonce(12) + tag(16) + + // The AAD is not part of the ciphertext, but we assert the contract by + // round-tripping with the expected AAD bytes and rejecting any other AAD. + let expectedAAD = coupleId.data(using: .utf8) + XCTAssertNotNil(expectedAAD) + let wrongAAD = "\(userId):\(questionId)".data(using: .utf8) + XCTAssertThrowsError( + try FieldEncryptor.decryptString( + innerCiphertext, + key: key.rawKey, + aad: wrongAAD + ) + ) + + // Re-encrypting the same plaintext with the same key but a different coupleId + // must produce a different inner ciphertext because the AAD changed. + let otherPayload = try AnswerCrypto.encrypt( + answerPlaintext: plaintext, + userId: userId, + questionId: questionId, + coupleId: "other-couple", + key: key + ) + let otherInnerBlob = try AnswerCrypto.decode(otherPayload.ciphertext) + XCTAssertNotEqual(payload.ciphertext, otherPayload.ciphertext) + XCTAssertNotEqual(innerCiphertext, otherInnerBlob.ciphertext) + } + + func testCrossPlatformInteropPlaceholder() { + // Real BouncyCastle ↔ CryptoKit cross-platform verification requires a Mac/CI run. + // The placeholder will be replaced in Batch 6 once the paired CI fixture exists. + // For now we document the AAD contract that Android expects: + // FieldEncryptor.kt uses AAD = coupleId.toByteArray(UTF_8). + let expectedAAD = coupleId.data(using: .utf8) + XCTAssertNotNil(expectedAAD) + XCTAssertEqual(expectedAAD, coupleId.data(using: .utf8)) + } + func testAADMismatchRejects() throws { let plaintext = "AAD-bound answer" let payload = try AnswerCrypto.encrypt( @@ -57,17 +115,17 @@ final class AnswerCryptoTests: XCTestCase { coupleId: coupleId, key: key ) - // Tamper the questionId in the payload so the AAD would differ. + // Tamper the coupleId in the payload so the AAD would differ. var tampered = payload tampered = SecureAnswerPayload( schemaVersion: payload.schemaVersion, - coupleId: payload.coupleId, + coupleId: payload.coupleId + "x", userId: payload.userId, - questionId: payload.questionId + "x", + questionId: payload.questionId, ciphertext: payload.ciphertext, createdAt: payload.createdAt ) - XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, key: key)) + XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, coupleId: coupleId + "x", key: key)) } func testTamperedCiphertextRejects() throws { @@ -91,7 +149,7 @@ final class AnswerCryptoTests: XCTestCase { ciphertext: tamperedCiphertext, createdAt: payload.createdAt ) - XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, key: key)) + XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, coupleId: coupleId, key: key)) } func testOuterWrapperTamperRejects() throws { diff --git a/iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift b/iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift index 0f9676e9..8db8f914 100644 --- a/iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift +++ b/iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift @@ -150,6 +150,87 @@ final class SealedAnswerCryptoTests: XCTestCase { XCTAssertEqual(recovered, samplePlaintext) } + func testCrossPlatformInteropPlaceholder() { + // Real BouncyCastle ↔ CryptoKit cross-platform verification requires a Mac/CI run. + // The placeholder will be replaced in Batch 6 once the paired CI fixture exists. + XCTAssertEqual(SealedAnswerCrypto.sealedPrefix, "sealed:v1:") + } + + func testFixtureFileLoadsAndProducesCanonicalJSON() throws { + let fixtures = try loadSealedFixtures() + for fixture in fixtures { + let plaintext = fixtureInputToPlaintext(fixture.input) + let actual = SealedAnswerCrypto.canonicalJSON(plaintext) + if fixture.expectedCanonicalJSON == "TODO_ANDROID_RUN" { + XCTSkip("Fixture \(fixture.name) needs Android canonical output") + } else { + XCTAssertEqual(actual, fixture.expectedCanonicalJSON, "Fixture \(fixture.name)") + } + } + } + + func testFixtureFileProducesMatchingCommitment() throws { + let fixtures = try loadSealedFixtures() + for fixture in fixtures { + let plaintext = fixtureInputToPlaintext(fixture.input) + let actual = try SealedAnswerCrypto.commit( + plaintext: plaintext, + coupleId: fixture.input.coupleId, + questionId: fixture.input.questionId, + userId: fixture.input.userId + ) + if fixture.expectedCommitmentSHA256 == "TODO_ANDROID_RUN" { + XCTSkip("Fixture \(fixture.name) needs Android commitment output") + } else { + XCTAssertEqual(actual, fixture.expectedCommitmentSHA256, "Fixture \(fixture.name)") + } + } + } + + // MARK: - Fixture helpers + + private struct SealedFixture: Decodable { + let name: String + let input: SealedFixtureInput + let expectedCanonicalJSON: String + let expectedCommitmentSHA256: String + + enum CodingKeys: String, CodingKey { + case name + case input + case expectedCanonicalJSON = "expected_canonical_json" + case expectedCommitmentSHA256 = "expected_commitment_sha256" + } + } + + private struct SealedFixtureInput: Decodable { + let plaintext: String + let questionId: String + let userId: String + let coupleId: String + } + + private func loadSealedFixtures() throws -> [SealedFixture] { + let bundle = Bundle(for: type(of: self)) + guard let url = bundle.url( + forResource: "sealed_answer_canonical_fixtures", + withExtension: "json" + ) else { + throw XCTSkip("sealed_answer_canonical_fixtures.json not found in test bundle") + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([SealedFixture].self, from: data) + } + + private func fixtureInputToPlaintext(_ input: SealedFixtureInput) -> SealedAnswerPlaintext { + // The plaintext string is treated as writtenText for these fixtures. + return SealedAnswerPlaintext( + writtenText: input.plaintext, + selectedOptionIds: [], + scaleValue: Int(input.plaintext) + ) + } + func testEmptySelectedOptionsAndNullText() throws { let plaintext = SealedAnswerPlaintext( writtenText: nil,