Closer/iphone/CloserTests/CryptoTests/AnswerCryptoTests.swift

172 lines
6.6 KiB
Swift
Raw Permalink Normal View History

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))
let recovered = try AnswerCrypto.decrypt(payload, coupleId: coupleId, key: key)
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)
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(
answerPlaintext: plaintext,
userId: userId,
questionId: questionId,
coupleId: coupleId,
key: key
)
// Tamper the coupleId in the payload so the AAD would differ.
var tampered = payload
tampered = SecureAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId + "x",
userId: payload.userId,
questionId: payload.questionId,
ciphertext: payload.ciphertext,
createdAt: payload.createdAt
)
XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, coupleId: coupleId + "x", key: key))
}
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
)
XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, coupleId: coupleId, key: key))
}
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))
}
}