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:
parent
60c0003114
commit
3d3209806c
|
|
@ -47,7 +47,7 @@ public enum AnswerCrypto {
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
key: CoupleKeyMaterial
|
key: CoupleKeyMaterial
|
||||||
) throws -> SecureAnswerPayload {
|
) throws -> SecureAnswerPayload {
|
||||||
let aad = answerAAD(userId: userId, questionId: questionId)
|
let aad = answerAAD(coupleId: coupleId)
|
||||||
let ciphertext = try FieldEncryptor.encryptString(
|
let ciphertext = try FieldEncryptor.encryptString(
|
||||||
answerPlaintext,
|
answerPlaintext,
|
||||||
key: key.rawKey,
|
key: key.rawKey,
|
||||||
|
|
@ -64,8 +64,8 @@ public enum AnswerCrypto {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypts a schemaVersion 2 secure answer payload.
|
/// Decrypts a schemaVersion 2 secure answer payload.
|
||||||
public static func decrypt(_ payload: SecureAnswerPayload, key: CoupleKeyMaterial) throws -> String {
|
public static func decrypt(_ payload: SecureAnswerPayload, coupleId: String, key: CoupleKeyMaterial) throws -> String {
|
||||||
let aad = answerAAD(userId: payload.userId, questionId: payload.questionId)
|
let aad = answerAAD(coupleId: coupleId)
|
||||||
return try FieldEncryptor.decryptString(
|
return try FieldEncryptor.decryptString(
|
||||||
payload.ciphertext,
|
payload.ciphertext,
|
||||||
key: key.rawKey,
|
key: key.rawKey,
|
||||||
|
|
@ -97,8 +97,8 @@ public enum AnswerCrypto {
|
||||||
return try JSONDecoder().decode(SecureAnswerPayload.self, from: data)
|
return try JSONDecoder().decode(SecureAnswerPayload.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func answerAAD(userId: String, questionId: String) -> Data? {
|
private static func answerAAD(coupleId: String) -> Data? {
|
||||||
"\(userId):\(questionId)".data(using: .utf8)
|
coupleId.data(using: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AnswerCryptoError: Error {
|
public enum AnswerCryptoError: Error {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -198,7 +198,7 @@ public final class AnswerRevealViewModel: ObservableObject {
|
||||||
guard decodedPayload.schemaVersion == AnswerCrypto.schemaVersion else {
|
guard decodedPayload.schemaVersion == AnswerCrypto.schemaVersion else {
|
||||||
throw AnswerError.unsupportedSchemaVersion(decodedPayload.schemaVersion)
|
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
|
partnerAnswer = plaintext
|
||||||
} catch {
|
} catch {
|
||||||
if let answerError = error as? AnswerError {
|
if let answerError = error as? AnswerError {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ final class AnswerCryptoTests: XCTestCase {
|
||||||
XCTAssertEqual(payload.schemaVersion, AnswerCrypto.schemaVersion)
|
XCTAssertEqual(payload.schemaVersion, AnswerCrypto.schemaVersion)
|
||||||
XCTAssertTrue(payload.ciphertext.hasPrefix(FieldEncryptor.prefix))
|
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)
|
XCTAssertEqual(recovered, plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,10 +44,68 @@ final class AnswerCryptoTests: XCTestCase {
|
||||||
XCTAssertEqual(decoded.coupleId, coupleId)
|
XCTAssertEqual(decoded.coupleId, coupleId)
|
||||||
XCTAssertEqual(decoded.schemaVersion, AnswerCrypto.schemaVersion)
|
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)
|
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 {
|
func testAADMismatchRejects() throws {
|
||||||
let plaintext = "AAD-bound answer"
|
let plaintext = "AAD-bound answer"
|
||||||
let payload = try AnswerCrypto.encrypt(
|
let payload = try AnswerCrypto.encrypt(
|
||||||
|
|
@ -57,17 +115,17 @@ final class AnswerCryptoTests: XCTestCase {
|
||||||
coupleId: coupleId,
|
coupleId: coupleId,
|
||||||
key: key
|
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
|
var tampered = payload
|
||||||
tampered = SecureAnswerPayload(
|
tampered = SecureAnswerPayload(
|
||||||
schemaVersion: payload.schemaVersion,
|
schemaVersion: payload.schemaVersion,
|
||||||
coupleId: payload.coupleId,
|
coupleId: payload.coupleId + "x",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
questionId: payload.questionId + "x",
|
questionId: payload.questionId,
|
||||||
ciphertext: payload.ciphertext,
|
ciphertext: payload.ciphertext,
|
||||||
createdAt: payload.createdAt
|
createdAt: payload.createdAt
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, key: key))
|
XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, coupleId: coupleId + "x", key: key))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTamperedCiphertextRejects() throws {
|
func testTamperedCiphertextRejects() throws {
|
||||||
|
|
@ -91,7 +149,7 @@ final class AnswerCryptoTests: XCTestCase {
|
||||||
ciphertext: tamperedCiphertext,
|
ciphertext: tamperedCiphertext,
|
||||||
createdAt: payload.createdAt
|
createdAt: payload.createdAt
|
||||||
)
|
)
|
||||||
XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, key: key))
|
XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, coupleId: coupleId, key: key))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOuterWrapperTamperRejects() throws {
|
func testOuterWrapperTamperRejects() throws {
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,87 @@ final class SealedAnswerCryptoTests: XCTestCase {
|
||||||
XCTAssertEqual(recovered, samplePlaintext)
|
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 {
|
func testEmptySelectedOptionsAndNullText() throws {
|
||||||
let plaintext = SealedAnswerPlaintext(
|
let plaintext = SealedAnswerPlaintext(
|
||||||
writtenText: nil,
|
writtenText: nil,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue