Skip to content

Commit 196d95c

Browse files
committed
Introduce support for FIDO2 to protect keys
Use FIDO2 devices with hmac-secret extension to generate key encryption key (KEK) instead of passphrase processed with argon2. Signed-off-by: Łukasz Stelmach <[email protected]>
1 parent 5b01535 commit 196d95c

File tree

9 files changed

+282
-22
lines changed

9 files changed

+282
-22
lines changed

scripts/shell_completions/bash/borg

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ _borg()
8585
local opts="-a --match-archives ${archive_filter_opts} ${common_opts}"
8686
;;
8787
*' repo-create '*)
88-
local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key ${common_opts}"
88+
local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key --fido2-device ${common_opts}"
8989
;;
9090
*' repo-list '*)
9191
local opts="--short --format --json ${common_opts} -a --match-archives ${archive_filter_opts} --deleted"
@@ -136,8 +136,9 @@ _borg()
136136
;;
137137
# umount
138138
# no specific options
139-
# key change-passphrase
140-
# no specific options
139+
*' change-passphrase '*)
140+
local opts="${common_opts} --fido2-device"
141+
;;
141142
*' change-location '*)
142143
local opts="${common_opts} keyfile repokey --keep"
143144
;;

scripts/shell_completions/zsh/_borg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ _borg-key() {
412412
case $line[1] in
413413
(change-passphrase)
414414
_arguments -s -w -S : \
415+
'--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \
415416
$common_options
416417
;;
417418
(export)
@@ -556,6 +557,7 @@ _borg-repo-create() {
556557

557558
_arguments -s -w -S : \
558559
'(-e --encryption)'{-e,--encryption}'=[select encryption key mode (required)]:MODE:(none authenticated authenticated-blake2 keyfile-aes-ocb repokey-aes-ocb keyfile-chacha20-poly1305 repokey-chacha20-poly1305 keyfile-blake2-aes-ocb repokey-blake2-aes-ocb keyfile-blake2-chacha20-poly1305 repokey-blake2-chacha20-poly1305)' \
560+
'--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \
559561
$common_repo_options \
560562
'--make-parent-dirs[create parent directories]'
561563
}

src/borg/archiver/key_cmds.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def do_change_passphrase(self, args, repository, manifest):
2323
key = manifest.key
2424
if not hasattr(key, "change_passphrase"):
2525
raise CommandError("This repository is not encrypted, cannot change the passphrase.")
26-
key.change_passphrase()
26+
key.change_passphrase(args)
2727
logger.info("Key updated")
2828
if hasattr(key, "find_key"):
2929
# print key location to make backing it up easier
@@ -241,6 +241,14 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
241241
help="change repository passphrase",
242242
)
243243
subparser.set_defaults(func=self.do_change_passphrase)
244+
subparser.add_argument(
245+
"--fido2-device",
246+
metavar="DEVICE",
247+
dest="fido2_device",
248+
default=None,
249+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` "
250+
"to list available devices.",
251+
)
244252

245253
change_location_epilog = process_epilog(
246254
"""

src/borg/archiver/repo_create_cmd.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,10 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser)
229229
help="copy the crypt_key (used for authenticated encryption) from the key of the other repo "
230230
"(default: new random key).",
231231
)
232+
subparser.add_argument(
233+
"--fido2-device",
234+
metavar="DEVICE",
235+
dest="fido2_device",
236+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` "
237+
"to list available devices.",
238+
)

src/borg/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@
157157
"pbkdf2": "sha256",
158158
# encrypt-then-MAC, kdf: argon2, encryption: chacha20, authentication: poly1305
159159
"argon2": "argon2 chacha20-poly1305",
160+
# Fido2 hmac-secret
161+
"fido2": "fido2 hmac-secret chacha20-poly1305",
160162
}
161163

162164

src/borg/crypto/key.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b, AES256_OCB, CHACHA20_POLY1305
3333
from . import low_level
3434

35+
from ..fido2 import has_fido2, Fido2Operations
36+
3537
# workaround for lost passphrase or key in "authenticated" or "authenticated-blake2" mode
3638
AUTHENTICATED_NO_KEY = "authenticated_no_key" in workarounds
3739

@@ -388,6 +390,7 @@ class FlexiKey:
388390
def detect(cls, repository, manifest_data, *, other=False):
389391
key = cls(repository)
390392
target = key.find_key()
393+
# TODO: ask for "PIN" when applicable
391394
prompt = "Enter passphrase for key %s: " % target
392395
passphrase = Passphrase.env_passphrase(other=other)
393396
if passphrase is None:
@@ -442,6 +445,8 @@ def decrypt_key_file(self, data, passphrase):
442445
return self.decrypt_key_file_pbkdf2(encrypted_key, passphrase)
443446
elif encrypted_key.algorithm == "argon2 chacha20-poly1305":
444447
return self.decrypt_key_file_argon2(encrypted_key, passphrase)
448+
elif encrypted_key.algorithm == "fido2 hmac-secret chacha20-poly1305":
449+
return self.decrypt_key_file_fido2(encrypted_key, passphrase)
445450
else:
446451
raise UnsupportedKeyFormatError()
447452

@@ -501,11 +506,26 @@ def decrypt_key_file_argon2(self, encrypted_key, passphrase):
501506
except low_level.IntegrityError:
502507
return None
503508

504-
def encrypt_key_file(self, data, passphrase, algorithm):
509+
def decrypt_key_file_fido2(self, encrypted_key, pin):
510+
device = Fido2Operations.find_device(encrypted_key.fido2_credential_id)
511+
operations = Fido2Operations(device, pin)
512+
secret = operations.use_hmac_hash(
513+
encrypted_key.salt,
514+
encrypted_key.fido2_credential_id,
515+
)
516+
ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0)
517+
try:
518+
return ae_cipher.decrypt(encrypted_key.data)
519+
except low_level.IntegrityError:
520+
return None
521+
522+
def encrypt_key_file(self, data, passphrase, algorithm, args):
505523
if algorithm == "sha256":
506524
return self.encrypt_key_file_pbkdf2(data, passphrase)
507525
elif algorithm == "argon2 chacha20-poly1305":
508526
return self.encrypt_key_file_argon2(data, passphrase)
527+
elif algorithm == "fido2 hmac-secret chacha20-poly1305":
528+
return self.encrypt_key_file_fido2(data, passphrase, args)
509529
else:
510530
raise ValueError(f"Unexpected algorithm: {algorithm}")
511531

@@ -531,22 +551,48 @@ def encrypt_key_file_argon2(self, data, passphrase):
531551
)
532552
return msgpack.packb(encrypted_key.as_dict())
533553

534-
def _save(self, passphrase, algorithm):
554+
def encrypt_key_file_fido2(self, data, pin, args):
555+
operations = Fido2Operations(args.fido2_device, pin)
556+
credential_id, salt, secret = operations.generate_hmac_hash(user=self.repository_id)
557+
ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0)
558+
encrypted_key = EncryptedKey(
559+
version=1,
560+
algorithm="fido2 hmac-secret chacha20-poly1305",
561+
salt=salt,
562+
data=ae_cipher.encrypt(data),
563+
fido2_credential_id=credential_id,
564+
)
565+
return msgpack.packb(encrypted_key.as_dict())
566+
567+
def _save(self, passphrase, algorithm, args):
535568
key = Key(
536569
version=2,
537570
repository_id=self.repository_id,
538571
crypt_key=self.crypt_key,
539572
id_key=self.id_key,
540573
chunk_seed=self.chunk_seed,
541574
)
542-
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm)
575+
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm, args)
543576
key_data = "\n".join(textwrap.wrap(binascii.b2a_base64(data).decode("ascii")))
544577
return key_data
545578

546-
def change_passphrase(self, passphrase=None):
547-
if passphrase is None:
548-
passphrase = Passphrase.new(allow_empty=True)
549-
self.save(self.target, passphrase, algorithm=self._encrypted_key_algorithm)
579+
def change_passphrase(self, args, passphrase=None):
580+
if args.fido2_device:
581+
operations = Fido2Operations(args.fido2_device)
582+
if operations.has_client_pin:
583+
# TODO: try to be more descriptive about the device
584+
passphrase = Passphrase.new(pin_prompt=f"Enter PIN for {args.fido2_device}: ", only_new=True)
585+
else:
586+
passphrase = Passphrase('')
587+
key_algorithm = KEY_ALGORITHMS["fido2"]
588+
else:
589+
if passphrase is None:
590+
passphrase = Passphrase.new(allow_empty=True, only_new=True)
591+
key_algorithm = self._encrypted_key_algorithm
592+
# If fido2 was used before change it to argon2
593+
if key_algorithm == KEY_ALGORITHMS["fido2"]:
594+
key_algorithm = KEY_ALGORITHMS["argon2"]
595+
self.save(self.target, passphrase, algorithm=key_algorithm, args=args)
550596

551597
@classmethod
552598
def create(cls, repository, args, *, other_key=None):
@@ -569,10 +615,15 @@ def create(cls, repository, args, *, other_key=None):
569615
key.init_from_given_data(crypt_key=crypt_key, id_key=other_key.id_key, chunk_seed=other_key.chunk_seed)
570616
else:
571617
key.init_from_random_data()
572-
passphrase = Passphrase.new(allow_empty=True)
618+
if args.fido2_device:
619+
key_algorithm = KEY_ALGORITHMS["fido2"]
620+
passphrase = Passphrase.new(pin_prompt="Enter PIN for {args.fido2_device}: ")
621+
else:
622+
key_algorithm = KEY_ALGORITHMS["argon2"]
623+
passphrase = Passphrase.new(allow_empty=True)
573624
key.init_ciphers()
574625
target = key.get_new_target(args)
575-
key.save(target, passphrase, create=True, algorithm=KEY_ALGORITHMS["argon2"])
626+
key.save(target, passphrase, key_algorithm, args, create=True)
576627
logger.info('Key in "%s" created.' % target)
577628
logger.info("Keep this key safe. Your data will be inaccessible without it.")
578629
return key
@@ -700,8 +751,8 @@ def load(self, target, passphrase):
700751
self.target = target
701752
return success
702753

703-
def save(self, target, passphrase, algorithm, create=False):
704-
key_data = self._save(passphrase, algorithm)
754+
def save(self, target, passphrase, algorithm, args, create=False):
755+
key_data = self._save(passphrase, algorithm, args)
705756
if self.STORAGE == KeyBlobStorage.KEYFILE:
706757
if create and os.path.isfile(target):
707758
# if a new keyfile key repository is created, ensure that an existing keyfile of another
@@ -788,8 +839,8 @@ def load(self, target, passphrase):
788839
self.logically_encrypted = False
789840
return success
790841

791-
def save(self, target, passphrase, algorithm, create=False):
792-
super().save(target, passphrase, algorithm, create=create)
842+
def save(self, target, passphrase, algorithm, args, create=False):
843+
super().save(target, passphrase, algorithm, args, create=create)
793844
self.logically_encrypted = False
794845

795846
def init_ciphers(self, manifest_data=None):

0 commit comments

Comments
 (0)