Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Aktuelle Datei",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"args": [
"--remove",
"--session",
"<redacted>",
"-c",
"keyname",
"-f",
"ssh-keys"
],
"justMyCode": true
}
]
}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Fetches SSH keys stored in Bitwarden vault and adds them to `ssh-agent`.
* `--debug`/`-d` - Show debug output
* `--foldername`/`-f` - Folder name to use to search for SSH keys _(default: ssh-agent)_
* `--customfield`/`-c` - Custom field name where private key filename is stored _(default: private)_
* `--passphrasefield`/`-p` - Custom field name where passphrase for the key is stored _(default: passphrase)_
* `--passphrasefield`/`-p` - Custom field name where passphrase for the key is stored _(default: passphrase)_
* `--session`/`-s` - session key of bitwarden
232 changes: 186 additions & 46 deletions bw_add_sshkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
import json
import logging
import os
import sys
import subprocess
from typing import Any, Callable, Dict, List, Optional
from cryptography.hazmat.primitives.serialization import load_ssh_private_key

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we don't have any external dependencies (besides bitwarden-cli and ssh-agent, of course). Adding an external library as dependency would make this tool harder to install.

We might need to rethink if that's the direction we'd like to go, but for now I'd say we better avoid requiring extra libs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @joaojacome,

Basically I agree with you, a new library is of course always a problem.
But I still decided to go this way, because at the moment the user has to manually remove the just added keys from the ssh-agent. From the user's point of view, I find this very inconvenient, so I decided to add the "Key Removal" function. Therefore the library is "required".

Additionally, due to your concerns, I tried to read up a bit about this library. Basically this library seems to be relatively well known, has thousands of downloads on pypi.org and a clean release management. I also checked the default package lists of Debian and Manjaro (desktop OS of my choice). In both operating systems this package (python-cryptography) is included in the default lists. So the coverage should be relatively wide.

Maybe you could also start a poll among users of the tool?

But it's your choice, of course. Feel free to reject the PR as is.

Greetings
hasechris :-)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a compromise to leverage the ssh-agent timeout, to have simple way to restrict the maximum time a key is active?

We could simply pass a -t argument to the subprocess call. That's straight forward, needs no additional libraries, but would prevent keys from staying in memory indefinitely.

from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.serialization import PublicFormat

from pkg_resources import parse_version


##################################################
### Main Functions
def memoize(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator function to cache the results of another function call
Expand All @@ -28,21 +33,7 @@ def memoized_func(*args: Any) -> Any:

return memoized_func


@memoize
def bwcli_version() -> str:
"""
Function to return the version of the Bitwarden CLI
"""
proc_version = subprocess.run(
['bw', '--version'],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
)
return proc_version.stdout


# maybe obsolete
@memoize
def cli_supports(feature: str) -> bool:
"""
Expand All @@ -56,12 +47,13 @@ def cli_supports(feature: str) -> bool:
return False


def get_session() -> str:
def get_session(session: str) -> str:
"""
Function to return a valid Bitwarden session
"""
# Check for an existing, user-supplied Bitwarden session
session = os.environ.get('BW_SESSION', '')
if not session:
session = os.environ.get('BW_SESSION', '')
if session:
logging.debug('Existing Bitwarden session found')
return session
Expand Down Expand Up @@ -101,6 +93,7 @@ def get_folders(session: str, foldername: str) -> str:
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
encoding = "utf-8",
)

folders = json.loads(proc_folders.stdout)
Expand Down Expand Up @@ -128,49 +121,75 @@ def folder_items(session: str, folder_id: str) -> List[Dict[str, Any]]:
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
encoding = "utf-8",
)

data: List[Dict[str, Any]] = json.loads(proc_items.stdout)

return data


def add_ssh_keys(
def manage_ssh_keys(
session: str,
items: List[Dict[str, Any]],
keyname: str,
pwkeyname: str,
args: argparse.Namespace,
items: List[Dict[str, Any]]
) -> None:
"""
Function to attempt to get keys from a vault item
"""
for item in items:
privatekey_found = False
for index,item in enumerate(items, start=1):
try:
private_key_file = [
k['value'] for k in item['fields'] if k['name'] == keyname
][0]
# IF we have a name specified, skip until the entry is found
#
if not args.entryname is None:

if item['name'] == args.entryname:
logging.info("Requested Entry %s found", item['name'])
else:
logging.debug("Skipping entry %s as it was not requested", item['name'])
if not privatekey_found and index >= len(items):
logging.error("Sadly the requested entry could not be found. Exiting now...")
continue

if args.assume_keyname:
private_key_file = args.entryname
privatekey_found = True
else:
private_key_file = [
k['value'] for k in item['fields'] if k['name'] == args.customfield
][0]
privatekey_found = True
else:
private_key_file = [
k['value'] for k in item['fields'] if k['name'] == args.customfield
][0]
except IndexError:
logging.warning('No "%s" field found for item %s', keyname, item['name'])
logging.info('No "%s" field found for item %s - skipping', args.customfield, item['name'])
continue
except KeyError as error:
logging.debug(
'No key "%s" found in item %s - skipping', error.args[0], item['name']
)
logging.debug('No key "%s" found in item %s - skipping', error.args[0], item['name'])
if not args.assume_keyname:
logging.error("No additional fields are specified for your entryname. Exiting now...")
break
continue
logging.debug('Private key file declared')

private_key_pw = None
try:
private_key_pw = [
k['value'] for k in item['fields'] if k['name'] == pwkeyname
][0]
logging.debug('Passphrase declared')
except IndexError:
logging.warning('No "%s" field found for item %s', pwkeyname, item['name'])
except KeyError as error:
logging.debug(
'No key "%s" found in item %s - skipping', error.args[0], item['name']
)
if not args.password_as_key_pass:
try:
private_key_pw = [
k['value'] for k in item['fields'] if k['name'] == args.passphrasefield
][0]
logging.debug('Passphrase declared')
except IndexError:
logging.warning('No "%s" field found for item %s - if ssh key needs password this is an error!', args.passphrasefield, item['name'])
except KeyError as error:
logging.debug(
'No key "%s" found in item %s - skipping', error.args[0], item['name']
)
else:
private_key_pw = item['login']['password']

try:
private_key_id = [
Expand All @@ -188,11 +207,18 @@ def add_ssh_keys(
logging.debug('Private key ID found')

try:
ssh_add(session, item['id'], private_key_id, private_key_pw)
if args.add and not args.remove:
ssh_add(session, item['id'], private_key_id, private_key_pw)
if args.remove and not args.add:
ssh_remove(session, item['id'], private_key_id, private_key_pw)
except subprocess.SubprocessError:
logging.warning('Could not add key to the SSH agent')

if privatekey_found:
break

##################################################
### Sub-Functions called from within Main Functions
def ssh_add(session: str, item_id: str, key_id: str, key_pw: Optional[str]) -> None:
"""
Function to get the key contents from the Bitwarden vault
Expand Down Expand Up @@ -238,14 +264,101 @@ def ssh_add(session: str, item_id: str, key_id: str, key_pw: Optional[str]) -> N
check=True,
)

def ssh_remove(session: str, item_id: str, key_id: str, key_pw: Optional[str]) -> None:
"""
Function to get the key contents from the Bitwarden vault
"""
logging.debug('Item ID: %s', item_id)
logging.debug('Key ID: %s', key_id)

proc_attachment = subprocess.run(
[
'bw',
'get',
'attachment',
key_id,
'--itemid',
item_id,
'--raw',
'--session',
session,
],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
)
ssh_key = str(proc_attachment.stdout)

if key_pw is None:
private_key_object = load_ssh_private_key(ssh_key.encode("ascii"),None)
else:
private_key_object = load_ssh_private_key(ssh_key.encode("utf-8"),key_pw.encode("utf-8"))

public_key_object = (private_key_object.public_key()).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("utf-8")

if key_pw:
envdict = dict(
os.environ,
SSH_ASKPASS=os.path.realpath(__file__),
SSH_KEY_PASSPHRASE=key_pw,
)
else:
envdict = dict(os.environ, SSH_ASKPASS_REQUIRE="never")

logging.debug("Running ssh-add")
# CAVEAT: `ssh-add` provides no useful output, even with maximum verbosity
subprocess.run(
['ssh-add', '-d', '-'],
input=public_key_object,
# Works even if ssh-askpass is not installed
env=envdict,
universal_newlines=True,
check=True,
)



@memoize
def bwcli_version() -> str:
"""
Function to return the version of the Bitwarden CLI
"""
proc_version = subprocess.run(
['bw', '--version'],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
)
return proc_version.stdout

##################################################
### Main Function!
if __name__ == '__main__':

def parse_args() -> argparse.Namespace:
"""
Function to parse command line arguments
"""
parser = argparse.ArgumentParser()
parser.add_argument(
'--add',
action='store_true',
help='DEFAULT: Add the specified ssh key to ssh-agent.'
)
parser.add_argument(
'--remove',
action='store_true',
help='Remove the specified ssh key to ssh-agent.'
)
parser.add_argument(
'--entryname',
help='Specify the name of the bitwarden entry to add/remove from ssh-agent.'
)
parser.add_argument(
'--assume-keyname',
action='store_true',
help='Assume the private key file is named identical to bitwarden entry.'
)
parser.add_argument(
'-d',
'--debug',
Expand All @@ -270,6 +383,17 @@ def parse_args() -> argparse.Namespace:
default='passphrase',
help='custom field name where key passphrase is stored',
)
parser.add_argument(
'--password-as-key-pass',
action='store_true',
help='Use normal password field as SSH Key Password',
)
parser.add_argument(
'-s',
'--session',
default='',
help='session key of bitwarden',
)

return parser.parse_args()

Expand All @@ -285,26 +409,42 @@ def main() -> None:
else:
loglevel = logging.INFO

if args.session == "<redacted>":
logging.error("Error: You didn't specify a session key in .vscode/launch.json")
sys.exit(1)

logging.basicConfig(level=loglevel)

logging.info("Syncing Bitwarden")
subprocess.run(
['bw', 'sync'],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
)

if args.add and args.remove:
logging.info('ERROR: --add and --remove are specified at the same time. Thats not alloweed.')
sys.exit(1)

try:
logging.info('Getting Bitwarden session')
session = get_session()
session = get_session(args.session)
logging.debug('Session = %s', session)

logging.info('Getting folder list')
folder_id = get_folders(session, args.foldername)

logging.info('Getting folder items')
items = folder_items(session, folder_id)

items = folder_items(session, folder_id)
logging.info('Attempting to add keys to ssh-agent')
add_ssh_keys(session, items, args.customfield, args.passphrasefield)
manage_ssh_keys(session, args, items)
except subprocess.CalledProcessError as error:
if error.stderr:
logging.error('"%s" error: %s', error.cmd[0], error.stderr)
logging.debug('Error running %s', error.cmd)


if os.environ.get('SSH_ASKPASS') and os.environ.get('SSH_ASKPASS') == os.path.realpath(__file__):
print(os.environ.get('SSH_KEY_PASSPHRASE'))
else:
Expand Down