3232from .low_level import bytes_to_int , num_cipher_blocks , hmac_sha256
3333from .low_level import AES256_OCB , CHACHA20_POLY1305
3434from . import low_level
35-
35+ from . fido2 import Fido2Operations
3636
3737def keyfile_name_for (content : bytes ) -> str :
3838 return sha256 (content ).hexdigest ()
@@ -508,6 +508,7 @@ class FlexiKey:
508508 def detect (cls , repository , manifest_data , * , other = False ):
509509 key = cls (repository )
510510 target = key .find_key ()
511+ # TODO: ask for "PIN" when applicable
511512 prompt = "Enter passphrase for key %s: " % target
512513 passphrase = Passphrase .env_passphrase (other = other )
513514 # a repository may have multiple borg keys, one per passphrase; try the
@@ -563,6 +564,8 @@ def decrypt_key_file(self, data, passphrase):
563564 self ._encrypted_key_label = encrypted_key .get ("label" )
564565 if encrypted_key .algorithm == "argon2 chacha20-poly1305" :
565566 return self .decrypt_key_file_argon2 (encrypted_key , passphrase )
567+ elif encrypted_key .algorithm == "fido2 hmac-secret chacha20-poly1305" :
568+ return self .decrypt_key_file_fido2 (encrypted_key , passphrase )
566569 else :
567570 raise UnsupportedKeyFormatError ()
568571
@@ -601,9 +604,21 @@ def decrypt_key_file_argon2(self, encrypted_key, passphrase):
601604 except low_level .IntegrityError :
602605 return None
603606
607+ def decrypt_key_file_fido2 (self , encrypted_key , pin ):
608+ device = Fido2Operations .find_device (encrypted_key .fido2_credential_id )
609+ operations = Fido2Operations (device , pin )
610+ secret = operations .use_hmac_hash (encrypted_key .salt , encrypted_key .fido2_credential_id )
611+ ae_cipher = CHACHA20_POLY1305 (key = secret , iv = 0 , header_len = 0 , aad_offset = 0 )
612+ try :
613+ return ae_cipher .decrypt (encrypted_key .data )
614+ except low_level .IntegrityError :
615+ return None
616+
604617 def encrypt_key_file (self , data , passphrase , algorithm , label = None ):
605618 if algorithm == "argon2 chacha20-poly1305" :
606619 return self .encrypt_key_file_argon2 (data , passphrase , label = label )
620+ elif algorithm == "fido2 hmac-secret chacha20-poly1305" :
621+ return self .encrypt_key_file_fido2 (data , passphrase , args )
607622 else :
608623 raise ValueError (f"Unexpected algorithm: { algorithm } " )
609624
@@ -623,25 +638,52 @@ def encrypt_key_file_argon2(self, data, passphrase, label=None):
623638 encrypted_key = EncryptedKey (** kw )
624639 return msgpack .packb (encrypted_key .as_dict ())
625640
626- def _save (self , passphrase , algorithm , label = None ):
641+ def encrypt_key_file_fido2 (self , data , pin , args ):
642+ operations = Fido2Operations (args .fido2_device , pin )
643+ credential_id , salt , secret = operations .generate_hmac_hash (user = self .repository_id )
644+ ae_cipher = CHACHA20_POLY1305 (key = secret , iv = 0 , header_len = 0 , aad_offset = 0 )
645+ encrypted_key = EncryptedKey (
646+ version = 1 ,
647+ algorithm = "fido2 hmac-secret chacha20-poly1305" ,
648+ salt = salt ,
649+ data = ae_cipher .encrypt (data ),
650+ fido2_credential_id = credential_id ,
651+ )
652+ return msgpack .packb (encrypted_key .as_dict ())
653+
654+ def _save (self , passphrase , algorithm , args , label = None ):
627655 key = Key (
628656 version = 2 ,
629657 repository_id = self .repository_id ,
630658 crypt_key = self .crypt_key ,
631659 id_key = self .id_key ,
632660 chunk_seed = self .chunk_seed ,
633661 )
634- data = self .encrypt_key_file (msgpack .packb (key .as_dict ()), passphrase , algorithm , label = label )
662+ data = self .encrypt_key_file (msgpack .packb (key .as_dict ()), passphrase , algorithm , args , label = label )
635663 key_data = "\n " .join (textwrap .wrap (binascii .b2a_base64 (data ).decode ("ascii" )))
636664 return key_data
637665
638- def change_passphrase (self , passphrase = None ):
639- if passphrase is None :
640- passphrase = Passphrase .new (allow_empty = True , only_new = True )
641- # replace the borg key we unlocked with: keep its label, write the new borg key, then
642- # (for repokey) delete the previously-loaded borg key (keyfile mode auto-erases it in save()).
643- old_id = self ._loaded_key_id
644- self .save (self .target , passphrase , algorithm = self ._encrypted_key_algorithm , label = self ._loaded_label )
666+ def change_passphrase (self , args , passphrase = None ):
667+ if args .fido2_device :
668+ operations = Fido2Operations (args .fido2_device )
669+ if operations .has_client_pin :
670+ # TODO: try to be more descriptive about the device
671+ passphrase = Passphrase .new (only_new = True , pin_prompt = f"Enter PIN for { args .fido2_device } : " )
672+ else :
673+ passphrase = Passphrase ("" )
674+ key_algorithm = KEY_ALGORITHMS ["fido2" ]
675+ else :
676+ if passphrase is None :
677+ passphrase = Passphrase .new (allow_empty = True , only_new = True )
678+ # replace the borg key we unlocked with: keep its label, write the new borg key, then
679+ # (for repokey) delete the previously-loaded borg key (keyfile mode auto-erases it in save()).
680+ old_id = self ._loaded_key_id
681+
682+ key_algorithm = self ._encrypted_key_algorithm
683+ # If fido2 was used before change it to argon2
684+ if key_algorithm == KEY_ALGORITHMS ["fido2" ]:
685+ key_algorithm = KEY_ALGORITHMS ["argon2" ]
686+ self .save (self .target , passphrase , algorithm = key_algorithm , args = args , label = self ._loaded_label ))
645687 if self .storage == KeyBlobStorage .REPO and old_id and hasattr (self .repository , "delete_key" ):
646688 if self ._loaded_key_id != old_id :
647689 self .repository .delete_key (old_id )
@@ -675,11 +717,16 @@ def create(cls, repository, args, *, other_key=None):
675717 key .init_from_given_data (crypt_key = crypt_key , id_key = id_key , chunk_seed = chunk_seed )
676718 else :
677719 key .init_from_random_data ()
678- passphrase = Passphrase .new (allow_empty = True )
720+ if args .fido2_device :
721+ key_algorithm = KEY_ALGORITHMS ["fido2" ]
722+ passphrase = Passphrase .new (pin_prompt = "Enter PIN for {args.fido2_device}: " )
723+ else :
724+ key_algorithm = KEY_ALGORITHMS ["argon2" ]
725+ passphrase = Passphrase .new (allow_empty = True )
679726 key .init_ciphers ()
680727 target = key .get_new_target (args )
681728 # the first borg key of a repository is the protected "admin" key.
682- key .save (target , passphrase , create = True , algorithm = KEY_ALGORITHMS [ "argon2" ] , label = ADMIN_LABEL )
729+ key .save (target , passphrase , key_algorithm , args , create = True , label = ADMIN_LABEL )
683730 logger .info ('Key in "%s" created.' % key .target )
684731 logger .info ("Keep this key safe. Your data will be inaccessible without it." )
685732 return key
@@ -873,10 +920,10 @@ def load(self, target, passphrase):
873920 else :
874921 return self .load_any (passphrase )
875922
876- def save (self , target , passphrase , algorithm , create = False , label = None , replace = True ):
923+ def save (self , target , passphrase , algorithm , args , create = False , label = None , replace = True ):
877924 # replace=True replaces the previously-loaded borg key (change-passphrase semantics);
878925 # replace=False adds an additional borg key, keeping the existing ones (key add).
879- key_data = self ._save (passphrase , algorithm , label = label )
926+ key_data = self ._save (passphrase , algorithm , args , label = label )
880927 if self .storage == KeyBlobStorage .KEYFILE :
881928 old_target = getattr (self , "target" , None )
882929 keys_dir = get_keys_dir ()
@@ -1222,8 +1269,8 @@ def load_any(self, passphrase):
12221269 self .logically_encrypted = False
12231270 return success
12241271
1225- def save (self , target , passphrase , algorithm , create = False , label = None , replace = True ):
1226- super ().save (target , passphrase , algorithm , create = create , label = label , replace = replace )
1272+ def save (self , target , passphrase , algorithm , args , create = False , label = None , replace = True ):
1273+ super ().save (target , passphrase , algorithm , args , create = create , label = label , replace = replace )
12271274 self .logically_encrypted = False
12281275
12291276 def init_from_given_data (self , * , crypt_key , id_key , chunk_seed ):
0 commit comments