168 lines
6.8 KiB
Swift
168 lines
6.8 KiB
Swift
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..<end], radix: 16) else { return nil }
|
|
data.append(byte)
|
|
}
|
|
return data
|
|
}
|
|
|
|
/// 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)."
|
|
)
|
|
}
|
|
}
|