diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 757820ac..991b072d 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -2683,6 +2683,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. + Format: "", where: + - is the YubiKey slot number (e.g., `1` or `2`). + - (optional) is the YubiKey's serial number (e.g., `1: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 7e6ac913..acaeb449 100644 --- a/borgmatic/hooks/credential/keepassxc.py +++ b/borgmatic/hooks/credential/keepassxc.py @@ -11,34 +11,31 @@ 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}' - ) - - return borgmatic.execute.execute_command_and_capture_output( + raise ValueError( f'KeePassXC database path does not exist: {database_path}') + + + # Build the keepassxc-cli command + command = ( tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli'))) - + ( - 'show', - '--show-protected', - '--attributes', - 'Password', - expanded_database_path, - attribute_name, - ) - ).rstrip(os.linesep) + + ('show', '--show-protected', '--attributes', 'Password') + + (('--key-file', hook_config['key_file']) if 'key_file' in hook_config else ()) + + (('--yubikey', hook_config['yubikey']) if 'yubikey' in hook_config else ()) + + (expanded_database_path, attribute_name) # Ensure database & entry are last + ) + + try: + return borgmatic.execute.execute_command_and_capture_output(command).rstrip(os.linesep) + except Exception as e: + raise ValueError(f'Failed to retrieve credential: {e}') diff --git a/tests/unit/hooks/credential/test_keepassxc.py b/tests/unit/hooks/credential/test_keepassxc.py index 0c460e23..d34b7232 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' + ) \ No newline at end of file