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
// / 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
}