import XCTest import CryptoKit @testable import Closer final class CoupleEncryptionManagerTests: XCTestCase { func testWrapUnwrapRoundTrip() throws { let key = try CoupleEncryptionManager.generateCoupleKey() let phrase = try RecoveryKeyManager.generatePhrase() let wrapped = try CoupleEncryptionManager.wrap(key, with: phrase) XCTAssertEqual(wrapped.kdfParams, CoupleEncryptionManager.kdfParamsTag) XCTAssertEqual(wrapped.kdfSalt.count, CoupleEncryptionManager.saltBytes) // Ciphertext includes nonce (12) + at least 1 byte plaintext + tag (16). XCTAssertGreaterThanOrEqual(wrapped.ciphertext.count, 29) let unwrapped = try CoupleEncryptionManager.unwrap(wrapped, with: phrase) XCTAssertEqual(unwrapped.rawKey.bytes, key.rawKey.bytes) } func testBadPhraseRejects() throws { let key = try CoupleEncryptionManager.generateCoupleKey() let phrase = try RecoveryKeyManager.generatePhrase() let wrapped = try CoupleEncryptionManager.wrap(key, with: phrase) let wrongPhrase = RecoveryKeyManager.normalize(phrase) + " wrong" XCTAssertThrowsError(try CoupleEncryptionManager.unwrap(wrapped, with: wrongPhrase)) } func testInvitePhraseEncryptionRoundTrip() throws { let phrase = try RecoveryKeyManager.generatePhrase() let inviteCode = "ABC123" let blob = try CoupleEncryptionManager.encryptRecoveryPhrase(phrase, with: inviteCode) let recovered = try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: inviteCode) XCTAssertEqual(recovered, phrase) } func testInvitePhraseBadCodeRejects() throws { let phrase = try RecoveryKeyManager.generatePhrase() let blob = try CoupleEncryptionManager.encryptRecoveryPhrase(phrase, with: "ABC123") XCTAssertThrowsError(try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: "ABC124")) } /// Loads the Argon2id canonical fixture and asserts libsodium output matches. /// /// The expected value is a TODO_ANDROID_RUN placeholder until a paired CI run fills it. func testArgon2idKnownVector() throws { let fixture = try loadArgon2idFixture() guard let salt = dataFromHex(fixture.saltHex) else { XCTFail("Invalid salt_hex in fixture") return } let kek = try CoupleEncryptionManager.unwrapKEK(phrase: fixture.passwordUTF8, salt: salt) let kekHex = kek.bytes.map { String(format: "%02x", $0) }.joined() if fixture.expectedOutputHex == "TODO_ANDROID_RUN" { XCTSkip("Argon2id fixture needs Android BouncyCastle output") } else { XCTAssertEqual(kekHex, fixture.expectedOutputHex, "Argon2id known vector mismatch") } } // MARK: - Fixture helpers private struct Argon2idFixture: Decodable { let name: String let passwordUTF8: String let saltHex: String let params: Argon2idParams let expectedOutputHex: String enum CodingKeys: String, CodingKey { case name case passwordUTF8 = "password_utf8" case saltHex = "salt_hex" case params case expectedOutputHex = "expected_output_hex" } } private struct Argon2idParams: Decodable { let mKib: Int let t: Int let p: Int let version: Int enum CodingKeys: String, CodingKey { case mKib = "m_kib" case t case p case version } } private func loadArgon2idFixture() throws -> Argon2idFixture { let bundle = Bundle(for: type(of: self)) guard let url = bundle.url( forResource: "argon2id_canonical_fixtures", withExtension: "json" ) else { throw XCTSkip("argon2id_canonical_fixtures.json not found in test bundle") } let data = try Data(contentsOf: url) let fixtures = try JSONDecoder().decode([Argon2idFixture].self, from: data) guard let first = fixtures.first else { throw XCTSkip("No Argon2id fixtures found") } return first } private func dataFromHex(_ hex: String) -> Data? { guard hex.count % 2 == 0 else { return nil } var data = Data(capacity: hex.count / 2) for i in stride(from: 0, to: hex.count, by: 2) { let start = hex.index(hex.startIndex, offsetBy: i) let end = hex.index(start, offsetBy: 2) guard let byte = UInt8(hex[start..