2026-06-28 17:14:50 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:19:00 -05:00
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:50 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|