Closer/iphone/CloserTests/CryptoTests/SealedAnswerCryptoTests.swift

255 lines
9.7 KiB
Swift
Raw Permalink Normal View History

import XCTest
import CryptoKit
@testable import Closer
final class SealedAnswerCryptoTests: XCTestCase {
private let coupleId = "couple-sealed-123"
private let userId = "user-sealed-456"
private let questionId = "question-sealed-789"
private var samplePlaintext: SealedAnswerPlaintext {
SealedAnswerPlaintext(
writtenText: "My secret answer",
selectedOptionIds: ["beta", "alpha", "gamma"],
scaleValue: 7
)
}
func testRoundTrip() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
XCTAssertEqual(payload.schemaVersion, SealedAnswerCrypto.schemaVersion)
XCTAssertTrue(payload.ciphertext.hasPrefix(SealedAnswerCrypto.sealedPrefix))
XCTAssertTrue(payload.commitment.hasPrefix(SealedAnswerCrypto.commitmentPrefix))
let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key)
XCTAssertEqual(recovered, samplePlaintext)
XCTAssertTrue(try SealedAnswerCrypto.verifyCommitment(payload, plaintext: recovered))
}
func testCanonicalJSONByteStability() throws {
// Ground truth: Android's manual canonical builder for this input.
let plaintext = SealedAnswerPlaintext(
writtenText: "Line\nTab\tQuote\"Back\\",
selectedOptionIds: ["z", "a", "m"],
scaleValue: 3
)
let canonical = SealedAnswerCrypto.canonicalJSON(plaintext)
let expected = "{\"scaleValue\":3,\"selectedOptionIds\":[\"a\",\"m\",\"z\"],\"writtenText\":\"Line\\nTab\\tQuote\\\"Back\\\\\"}"
XCTAssertEqual(canonical, expected)
}
func testCommitmentFormatAndLength() throws {
let commitment = try SealedAnswerCrypto.commit(
plaintext: samplePlaintext,
coupleId: coupleId,
questionId: questionId,
userId: userId
)
XCTAssertTrue(commitment.hasPrefix("sha256:"))
// 7 + 43 chars = 50 chars.
XCTAssertEqual(commitment.count, 50)
}
func testAADMismatchRejects() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
var payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
payload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId + "x",
commitment: payload.commitment,
ciphertext: payload.ciphertext,
nonce: payload.nonce,
createdAt: payload.createdAt
)
XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key))
}
func testTamperedCiphertextRejects() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
var chars = Array(payload.ciphertext)
let prefixEnd = SealedAnswerCrypto.sealedPrefix.count
chars[prefixEnd + 5] ^= 0x01
let tampered = String(chars)
let tamperedPayload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId,
commitment: payload.commitment,
ciphertext: tampered,
nonce: payload.nonce,
createdAt: payload.createdAt
)
XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key))
}
func testTamperedCommitmentFailsVerification() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let tamperedCommitment = payload.commitment + "x"
let tamperedPayload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId,
commitment: tamperedCommitment,
ciphertext: payload.ciphertext,
nonce: payload.nonce,
createdAt: payload.createdAt
)
let recovered = try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key)
XCTAssertFalse(try SealedAnswerCrypto.verifyCommitment(tamperedPayload, plaintext: recovered))
}
func testEncodeDecodeRoundTrip() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let encoded = try SealedAnswerCrypto.encode(payload)
XCTAssertTrue(encoded.hasPrefix(SealedAnswerCrypto.sealedPrefix))
let decoded = try SealedAnswerCrypto.decode(encoded)
XCTAssertEqual(decoded.userId, userId)
XCTAssertEqual(decoded.questionId, questionId)
XCTAssertEqual(decoded.schemaVersion, SealedAnswerCrypto.schemaVersion)
let recovered = try SealedAnswerCrypto.decrypt(decoded, oneTimeKey: key)
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,
selectedOptionIds: [],
scaleValue: nil
)
let canonical = SealedAnswerCrypto.canonicalJSON(plaintext)
XCTAssertEqual(canonical, "{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":null}")
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: plaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key)
XCTAssertEqual(recovered, plaintext)
}
}