2026-06-28 17:04:47 -05:00
|
|
|
import XCTest
|
|
|
|
|
import CryptoKit
|
|
|
|
|
@testable import Closer
|
|
|
|
|
|
|
|
|
|
final class AnswerCryptoTests: XCTestCase {
|
|
|
|
|
private let coupleId = "couple-test-456"
|
|
|
|
|
private let userId = "user-test-789"
|
|
|
|
|
private let questionId = "question-test-abc"
|
|
|
|
|
private var key: CoupleKeyMaterial {
|
|
|
|
|
CoupleKeyMaterial(rawBytes: Data(repeating: 0xAB, count: 32))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptDecryptRoundTrip() throws {
|
|
|
|
|
let plaintext = "This is my private answer."
|
|
|
|
|
let payload = try AnswerCrypto.encrypt(
|
|
|
|
|
answerPlaintext: plaintext,
|
|
|
|
|
userId: userId,
|
|
|
|
|
questionId: questionId,
|
|
|
|
|
coupleId: coupleId,
|
|
|
|
|
key: key
|
|
|
|
|
)
|
|
|
|
|
XCTAssertEqual(payload.schemaVersion, AnswerCrypto.schemaVersion)
|
|
|
|
|
XCTAssertTrue(payload.ciphertext.hasPrefix(FieldEncryptor.prefix))
|
|
|
|
|
|
2026-06-28 17:19:00 -05:00
|
|
|
let recovered = try AnswerCrypto.decrypt(payload, coupleId: coupleId, key: key)
|
2026-06-28 17:04:47 -05:00
|
|
|
XCTAssertEqual(recovered, plaintext)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncodeDecodeRoundTrip() throws {
|
|
|
|
|
let plaintext = "Round-trip via outer wrapper"
|
|
|
|
|
let payload = try AnswerCrypto.encrypt(
|
|
|
|
|
answerPlaintext: plaintext,
|
|
|
|
|
userId: userId,
|
|
|
|
|
questionId: questionId,
|
|
|
|
|
coupleId: coupleId,
|
|
|
|
|
key: key
|
|
|
|
|
)
|
|
|
|
|
let encoded = try AnswerCrypto.encode(payload)
|
|
|
|
|
XCTAssertTrue(encoded.hasPrefix(FieldEncryptor.prefix))
|
|
|
|
|
|
|
|
|
|
let decoded = try AnswerCrypto.decode(encoded)
|
|
|
|
|
XCTAssertEqual(decoded.userId, userId)
|
|
|
|
|
XCTAssertEqual(decoded.questionId, questionId)
|
|
|
|
|
XCTAssertEqual(decoded.coupleId, coupleId)
|
|
|
|
|
XCTAssertEqual(decoded.schemaVersion, AnswerCrypto.schemaVersion)
|
|
|
|
|
|
2026-06-28 17:19:00 -05:00
|
|
|
let recovered = try AnswerCrypto.decrypt(decoded, coupleId: coupleId, key: key)
|
2026-06-28 17:04:47 -05:00
|
|
|
XCTAssertEqual(recovered, plaintext)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:19:00 -05:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:04:47 -05:00
|
|
|
func testAADMismatchRejects() throws {
|
|
|
|
|
let plaintext = "AAD-bound answer"
|
|
|
|
|
let payload = try AnswerCrypto.encrypt(
|
|
|
|
|
answerPlaintext: plaintext,
|
|
|
|
|
userId: userId,
|
|
|
|
|
questionId: questionId,
|
|
|
|
|
coupleId: coupleId,
|
|
|
|
|
key: key
|
|
|
|
|
)
|
2026-06-28 17:19:00 -05:00
|
|
|
// Tamper the coupleId in the payload so the AAD would differ.
|
2026-06-28 17:04:47 -05:00
|
|
|
var tampered = payload
|
|
|
|
|
tampered = SecureAnswerPayload(
|
|
|
|
|
schemaVersion: payload.schemaVersion,
|
2026-06-28 17:19:00 -05:00
|
|
|
coupleId: payload.coupleId + "x",
|
2026-06-28 17:04:47 -05:00
|
|
|
userId: payload.userId,
|
2026-06-28 17:19:00 -05:00
|
|
|
questionId: payload.questionId,
|
2026-06-28 17:04:47 -05:00
|
|
|
ciphertext: payload.ciphertext,
|
|
|
|
|
createdAt: payload.createdAt
|
|
|
|
|
)
|
2026-06-28 17:19:00 -05:00
|
|
|
XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, coupleId: coupleId + "x", key: key))
|
2026-06-28 17:04:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testTamperedCiphertextRejects() throws {
|
|
|
|
|
let plaintext = "Tamper me"
|
|
|
|
|
let payload = try AnswerCrypto.encrypt(
|
|
|
|
|
answerPlaintext: plaintext,
|
|
|
|
|
userId: userId,
|
|
|
|
|
questionId: questionId,
|
|
|
|
|
coupleId: coupleId,
|
|
|
|
|
key: key
|
|
|
|
|
)
|
|
|
|
|
var chars = Array(payload.ciphertext)
|
|
|
|
|
let prefixEnd = FieldEncryptor.prefix.count
|
|
|
|
|
chars[prefixEnd + 5] ^= 0x01
|
|
|
|
|
let tamperedCiphertext = String(chars)
|
|
|
|
|
let tamperedPayload = SecureAnswerPayload(
|
|
|
|
|
schemaVersion: payload.schemaVersion,
|
|
|
|
|
coupleId: payload.coupleId,
|
|
|
|
|
userId: payload.userId,
|
|
|
|
|
questionId: payload.questionId,
|
|
|
|
|
ciphertext: tamperedCiphertext,
|
|
|
|
|
createdAt: payload.createdAt
|
|
|
|
|
)
|
2026-06-28 17:19:00 -05:00
|
|
|
XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, coupleId: coupleId, key: key))
|
2026-06-28 17:04:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testOuterWrapperTamperRejects() throws {
|
|
|
|
|
let plaintext = "Outer tamper"
|
|
|
|
|
let payload = try AnswerCrypto.encrypt(
|
|
|
|
|
answerPlaintext: plaintext,
|
|
|
|
|
userId: userId,
|
|
|
|
|
questionId: questionId,
|
|
|
|
|
coupleId: coupleId,
|
|
|
|
|
key: key
|
|
|
|
|
)
|
|
|
|
|
let encoded = try AnswerCrypto.encode(payload)
|
|
|
|
|
var chars = Array(encoded)
|
|
|
|
|
let prefixEnd = FieldEncryptor.prefix.count
|
|
|
|
|
chars[prefixEnd + 3] ^= 0x01
|
|
|
|
|
let tampered = String(chars)
|
|
|
|
|
XCTAssertThrowsError(try AnswerCrypto.decode(tampered))
|
|
|
|
|
}
|
|
|
|
|
}
|