diff --git a/NEWS b/NEWS index 87c1db3e8..b2a83ec70 100644 --- a/NEWS +++ b/NEWS @@ -32,6 +32,7 @@ "working_directory" are used. * #1044: Fix an error in the systemd credential hook when the credential name contains a "." character. + * #1047: Add "key-file" and "yubikey" options to the KeePassXC credential hook. * #1048: Fix a "no such file or directory" error in ZFS, Btrfs, and LVM hooks with nested directories that reside on separate devices/filesystems. * #1050: Fix a failure in the "spot" check when the archive contains a symlink. diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 873887350..aa56a8bea 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -2790,6 +2790,20 @@ properties: description: | Command to use instead of "keepassxc-cli". example: /usr/local/bin/keepassxc-cli + key_file: + type: string + description: | + Path to a key file for unlocking the KeePassXC database. + example: /path/to/keyfile + yubikey: + type: string + description: | + YubiKey slot and optional serial number used to access the + KeePassXC database. The format is "", where: + * is the YubiKey slot number (e.g., `1` or `2`). + * (optional) is the YubiKey's serial number (e.g., + `7370001`). + example: "1:7370001" description: | Configuration for integration with the KeePassXC password manager. default_actions: diff --git a/borgmatic/hooks/credential/keepassxc.py b/borgmatic/hooks/credential/keepassxc.py index 7e6ac9134..c3605fcfc 100644 --- a/borgmatic/hooks/credential/keepassxc.py +++ b/borgmatic/hooks/credential/keepassxc.py @@ -11,34 +11,35 @@ def load_credential(hook_config, config, credential_parameters): ''' Given the hook configuration dict, the configuration dict, and a credential parameters tuple containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch - the corresponidng KeePassXC credential and return it. + the corresponding KeePassXC credential and return it. Raise ValueError if keepassxc-cli can't retrieve the credential. ''' try: (database_path, attribute_name) = credential_parameters except ValueError: - path_and_name = ' '.join(credential_parameters) - - raise ValueError( - f'Cannot load credential with invalid KeePassXC database path and attribute name: "{path_and_name}"' - ) + raise ValueError(f'Invalid KeePassXC credential parameters: {credential_parameters}') expanded_database_path = os.path.expanduser(database_path) if not os.path.exists(expanded_database_path): - raise ValueError( - f'Cannot load credential because KeePassXC database path does not exist: {database_path}' - ) + raise ValueError(f'KeePassXC database path does not exist: {database_path}') - return borgmatic.execute.execute_command_and_capture_output( + # Build the keepassxc-cli command. + command = ( tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli'))) + + ('show', '--show-protected', '--attributes', 'Password') + ( - 'show', - '--show-protected', - '--attributes', - 'Password', - expanded_database_path, - attribute_name, + ('--key-file', hook_config['key_file']) + if hook_config and hook_config.get('key_file') + else () ) - ).rstrip(os.linesep) + + ( + ('--yubikey', hook_config['yubikey']) + if hook_config and hook_config.get('yubikey') + else () + ) + + (expanded_database_path, attribute_name) # Ensure database and entry are last. + ) + + return borgmatic.execute.execute_command_and_capture_output(command).rstrip(os.linesep) diff --git a/tests/unit/hooks/credential/test_keepassxc.py b/tests/unit/hooks/credential/test_keepassxc.py index 0c460e233..ccb9173b5 100644 --- a/tests/unit/hooks/credential/test_keepassxc.py +++ b/tests/unit/hooks/credential/test_keepassxc.py @@ -116,3 +116,104 @@ def test_load_credential_with_expanded_directory_with_present_database_fetches_p ) == 'password' ) + + +def test_load_credential_with_key_file(): + flexmock(module.os.path).should_receive('expanduser').with_args('database.kdbx').and_return( + 'database.kdbx' + ) + flexmock(module.os.path).should_receive('exists').and_return(True) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args( + ( + 'keepassxc-cli', + 'show', + '--show-protected', + '--attributes', + 'Password', + '--key-file', + '/path/to/keyfile', + 'database.kdbx', + 'mypassword', + ) + ).and_return( + 'password' + ).once() + + assert ( + module.load_credential( + hook_config={'key_file': '/path/to/keyfile'}, + config={}, + credential_parameters=('database.kdbx', 'mypassword'), + ) + == 'password' + ) + + +def test_load_credential_with_yubikey(): + flexmock(module.os.path).should_receive('expanduser').with_args('database.kdbx').and_return( + 'database.kdbx' + ) + flexmock(module.os.path).should_receive('exists').and_return(True) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args( + ( + 'keepassxc-cli', + 'show', + '--show-protected', + '--attributes', + 'Password', + '--yubikey', + '1:7370001', + 'database.kdbx', + 'mypassword', + ) + ).and_return( + 'password' + ).once() + + assert ( + module.load_credential( + hook_config={'yubikey': '1:7370001'}, + config={}, + credential_parameters=('database.kdbx', 'mypassword'), + ) + == 'password' + ) + + +def test_load_credential_with_key_file_and_yubikey(): + flexmock(module.os.path).should_receive('expanduser').with_args('database.kdbx').and_return( + 'database.kdbx' + ) + flexmock(module.os.path).should_receive('exists').and_return(True) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args( + ( + 'keepassxc-cli', + 'show', + '--show-protected', + '--attributes', + 'Password', + '--key-file', + '/path/to/keyfile', + '--yubikey', + '2', + 'database.kdbx', + 'mypassword', + ) + ).and_return( + 'password' + ).once() + + assert ( + module.load_credential( + hook_config={'key_file': '/path/to/keyfile', 'yubikey': '2'}, + config={}, + credential_parameters=('database.kdbx', 'mypassword'), + ) + == 'password' + )