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")) } /// Known-vector test (iOS self-consistency). /// /// Uses the first 10 words of the bundled wordlist and a deterministic salt. /// The expected SHA-256 of the unwrapped key is a placeholder until the test /// can be executed on macOS/CI where libsodium Argon2id is available. /// /// TODO(Batch 3 follow-up): replace `expectedHash` with the real output from /// a Mac/CI run, then add a matching BouncyCastle vector from Android. func testKnownVectorUnwrapPlaceholder() throws { let words = try Wordlist.load() let phrase = words.prefix(10).joined(separator: " ") let salt = Data((0x00...0x0F).map { $0 }) let key = CoupleKeyMaterial(rawBytes: Data(repeating: 0xCD, count: 32)) let kek = try CoupleEncryptionManager.unwrapKEK(phrase: phrase, salt: salt) let wrappedCiphertext = try FieldEncryptor.encrypt( key.rawKey.bytes, key: kek, aad: CoupleEncryptionManager.coupleKeyAAD.data(using: .utf8) ) let wrapped = WrappedCoupleKey( ciphertext: wrappedCiphertext, kdfSalt: salt, kdfParams: CoupleEncryptionManager.kdfParamsTag ) let unwrapped = try CoupleEncryptionManager.unwrap(wrapped, with: phrase) let hash = SHA256.hash(data: unwrapped.rawKey.bytes) let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined() // Placeholder: libsodium Argon2id cannot run in this Linux environment. // Replace with the real hash after a Mac/CI run. let placeholderHash = "0000000000000000000000000000000000000000000000000000000000000000" XCTAssertNotEqual(hashHex, placeholderHash, "Expected hash placeholder must be updated on macOS/CI") // The real assertion (commented out until the Mac/CI run provides the value): // let expectedHash = "REPLACE_WITH_MAC_CI_HASH" // XCTAssertEqual(hashHex, expectedHash) // Document cross-platform gap in the test output. XCTAssertTrue( hashHex.count == 64, "Hash must be 64 hex chars. Cross-platform BouncyCastle↔libsodium verification requires a paired CI run (Android emulator + iOS simulator + shared fixture)." ) } }