2026-06-28 16:56:51 -05:00
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 )
// C i p h e r t e x t i n c l u d e s n o n c e ( 1 2 ) + a t l e a s t 1 b y t e p l a i n t e x t + t a g ( 1 6 ) .
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 " ) )
}
2026-06-28 17:04:47 -05:00
2026-06-28 17:22:31 -05:00
// / L o a d s t h e A r g o n 2 i d c a n o n i c a l f i x t u r e a n d a s s e r t s l i b s o d i u m o u t p u t m a t c h e s .
// /
// / T h e e x p e c t e d v a l u e i s a T O D O _ A N D R O I D _ R U N p l a c e h o l d e r u n t i l a p a i r e d C I r u n f i l l s i t .
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: - F i x t u r e h e l p e r s
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
}
2026-06-28 17:04:47 -05:00
// / K n o w n - v e c t o r t e s t ( i O S s e l f - c o n s i s t e n c y ) .
// /
// / U s e s t h e f i r s t 1 0 w o r d s o f t h e b u n d l e d w o r d l i s t a n d a d e t e r m i n i s t i c s a l t .
// / T h e e x p e c t e d S H A - 2 5 6 o f t h e u n w r a p p e d k e y i s a p l a c e h o l d e r u n t i l t h e t e s t
// / c a n b e e x e c u t e d o n m a c O S / C I w h e r e l i b s o d i u m A r g o n 2 i d i s a v a i l a b l e .
// /
// / T O D O ( B a t c h 3 f o l l o w - u p ) : r e p l a c e ` e x p e c t e d H a s h ` w i t h t h e r e a l o u t p u t f r o m
// / a M a c / C I r u n , t h e n a d d a m a t c h i n g B o u n c y C a s t l e v e c t o r f r o m A n d r o i d .
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 ( )
// P l a c e h o l d e r : l i b s o d i u m A r g o n 2 i d c a n n o t r u n i n t h i s L i n u x e n v i r o n m e n t .
// R e p l a c e w i t h t h e r e a l h a s h a f t e r a M a c / C I r u n .
let placeholderHash = " 0000000000000000000000000000000000000000000000000000000000000000 "
XCTAssertNotEqual ( hashHex , placeholderHash , " Expected hash placeholder must be updated on macOS/CI " )
// T h e r e a l a s s e r t i o n ( c o m m e n t e d o u t u n t i l t h e M a c / C I r u n p r o v i d e s t h e v a l u e ) :
// l e t e x p e c t e d H a s h = " R E P L A C E _ W I T H _ M A C _ C I _ H A S H "
// X C T A s s e r t E q u a l ( h a s h H e x , e x p e c t e d H a s h )
// D o c u m e n t c r o s s - p l a t f o r m g a p i n t h e t e s t o u t p u t .
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). "
)
}
2026-06-28 16:56:51 -05:00
}