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)

This commit is contained in:
null 2026-06-28 17:19:00 -05:00
parent 60c0003114
commit 3d3209806c
6 changed files with 194 additions and 13 deletions

View File

@ -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 {

View File

@ -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"
}
]

View File

@ -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"
}
]

View File

@ -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 {

View File

@ -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 {

View File

@ -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,