Compare commits

..

No commits in common. "6237a3604d640533ad4123d23e23ddfd4e3666d2" and "b378de41634458c65c9092a02238db110ce43c13" have entirely different histories.

11 changed files with 124 additions and 295 deletions

View file

@ -1,25 +0,0 @@
name: black
on:
push:
branches:
- master
pull_request: {}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install black
run: |
pip install --upgrade pip
python3.10 -m venv env
source env/bin/activate
pip install black
- name: Run black
run: |
env/bin/black --check --verbose bw_add_sshkeys.py

View file

@ -1,25 +0,0 @@
name: flake8
on:
push:
branches:
- master
pull_request: {}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install flake8
run: |
pip install --upgrade pip
python3.10 -m venv env
source env/bin/activate
pip install flake8
- name: Run black
run: |
env/bin/flake8 bw_add_sshkeys.py

View file

@ -1,25 +0,0 @@
name: mypy
on:
push:
branches:
- master
pull_request: {}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install mypy
run: |
pip install --upgrade pip
python3.10 -m venv env
source env/bin/activate
pip install mypy types-setuptools
- name: Run black
run: |
env/bin/mypy bw_add_sshkeys.py

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
.pyenv/
.mypy_cache/
env/

View file

@ -1,12 +1,9 @@
# Bitwarden SSH Agent
## Requirements
* You need to have the [Bitwarden CLI tool](https://bitwarden.com/help/cli/) installed and available in the `$PATH` as `bw`. See below for detailed instructions.
* You need to have the [Bitwarden CLI tool](https://github.com/bitwarden/cli) installed and available in the `$PATH` as `bw`.
* `ssh-agent` must be running in the current session.
## Installation
Just save the file `bw_add_sshkeys.py` in a folder where it can by found when calling it from the command line. On linux you can see these folders by running `echo $PATH` from the command line. To install for a single user, you can - for example - save the script under `~/.local/bin/` and make it executable by running `chmod +x ~/.local/bin/bw_add_sshkeys.py`.
## What does it do?
Fetches SSH keys stored in Bitwarden vault and adds them to `ssh-agent`.
@ -23,19 +20,11 @@ Fetches SSH keys stored in Bitwarden vault and adds them to `ssh-agent`.
2. Add an new secure note to that folder.
3. Upload the private key as an attachment.
4. Add the custom field `private` (can be overridden on the command line), containing the file name of the private key attachment.
5. (optional) If your key is encrypted with passphrase and you want it to decrypt automatically, save passphrase into custom field `passphrase` (field name can be overriden on the command line). You can create this field as `hidden` if you don't want the passphrase be displayed by default.
5. (optional) If your key is encrypted with passphrase and you want it to decrypt automatically, save passphrase into custom field `passphrase` (field name can be overriden on the command line)
6. Repeat steps 2-5 for each subsequent key
## Command line overrides
* `--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)_
* `--session`/`-s` - session key of bitwarden
## Setting up the Bitwarden CLI tool
Download the [Bitwarden CLI](https://bitwarden.com/help/cli/), extract the binary from the zip file, make it executable and add it to your path so that it can be found on the command line.
On linux you will likely want to move the executable to `~/.local/bin` and make it executable `chmod +x ~/.local/bin/bw`. `~/.local/bin` is likely already set as a path. You can confirm that by running `which bw`, which should return the path to the executable. You can use the same approach to turn `bw_add_sshkeys.py` into an executable.
If you want to build the Bitwarden CLI by yourself, see [these instructions on the bitwarden github page](https://contributing.bitwarden.com/getting-started/clients/cli).
* `--passphrasefield`/`-p` - Custom field name where passphrase for the key is stored _(default: passphrase)_

View file

@ -8,32 +8,76 @@ import json
import logging
import os
import subprocess
from typing import Any
from typing import Any, Callable, Dict, List, Optional
from pkg_resources import parse_version
def get_session(session: str) -> str:
def memoize(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator function to cache the results of another function call
"""
cache: Dict[Any, Callable[..., Any]] = {}
def memoized_func(*args: Any) -> Any:
if args in cache:
return cache[args]
result = func(*args)
cache[args] = result
return result
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
@memoize
def cli_supports(feature: str) -> bool:
"""
Function to return whether the current Bitwarden CLI supports a particular
feature
"""
version = parse_version(bwcli_version())
if feature == 'nointeraction' and version >= parse_version('1.9.0'):
return True
return False
def get_session() -> str:
"""
Function to return a valid Bitwarden session
"""
# Check for an existing, user-supplied Bitwarden session
if not session:
session = os.environ.get("BW_SESSION", "")
session = os.environ.get('BW_SESSION', '')
if session:
logging.debug("Existing Bitwarden session found")
logging.debug('Existing Bitwarden session found')
return session
# Check if we're already logged in
proc_logged = subprocess.run(["bw", "login", "--check", "--quiet"], check=False)
proc_logged = subprocess.run(['bw', 'login', '--check', '--quiet'], check=True)
if proc_logged.returncode:
logging.debug("Not logged into Bitwarden")
operation = "login"
logging.debug('Not logged into Bitwarden')
operation = 'login'
else:
logging.debug("Bitwarden vault is locked")
operation = "unlock"
logging.debug('Bitwarden vault is locked')
operation = 'unlock'
proc_session = subprocess.run(
["bw", "--raw", operation],
['bw', '--raw', operation],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
@ -50,52 +94,50 @@ def get_folders(session: str, foldername: str) -> str:
"""
Function to return the ID of the folder that matches the provided name
"""
logging.debug("Folder name: %s", foldername)
logging.debug('Folder name: %s', foldername)
proc_folders = subprocess.run(
["bw", "list", "folders", "--search", foldername, "--session", session],
['bw', 'list', 'folders', '--search', foldername, '--session', session],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
encoding="utf-8",
)
folders = json.loads(proc_folders.stdout)
if not folders:
logging.error('"%s" folder not found', foldername)
return ""
return ''
# Do we have any folders
if len(folders) != 1:
logging.error('%d folders with the name "%s" found', len(folders), foldername)
return ""
return ''
return str(folders[0]["id"])
return str(folders[0]['id'])
def folder_items(session: str, folder_id: str) -> list[dict[str, Any]]:
def folder_items(session: str, folder_id: str) -> List[Dict[str, Any]]:
"""
Function to return items from a folder
"""
logging.debug("Folder ID: %s", folder_id)
logging.debug('Folder ID: %s', folder_id)
proc_items = subprocess.run(
["bw", "list", "items", "--folderid", folder_id, "--session", session],
['bw', 'list', 'items', '--folderid', folder_id, '--session', session],
stdout=subprocess.PIPE,
universal_newlines=True,
check=True,
encoding="utf-8",
)
data: list[dict[str, Any]] = json.loads(proc_items.stdout)
data: List[Dict[str, Any]] = json.loads(proc_items.stdout)
return data
def add_ssh_keys(
session: str,
items: list[dict[str, Any]],
items: List[Dict[str, Any]],
keyname: str,
pwkeyname: str,
) -> None:
@ -105,69 +147,69 @@ def add_ssh_keys(
for item in items:
try:
private_key_file = [
k["value"] for k in item["fields"] if k["name"] == keyname
k['value'] for k in item['fields'] if k['name'] == keyname
][0]
except IndexError:
logging.warning('No "%s" field found for item %s', keyname, item["name"])
logging.warning('No "%s" field found for item %s', keyname, item['name'])
continue
except KeyError as error:
logging.debug(
'No key "%s" found in item %s - skipping', error.args[0], item["name"]
'No key "%s" found in item %s - skipping', error.args[0], item['name']
)
continue
logging.debug("Private key file declared")
logging.debug('Private key file declared')
private_key_pw = ""
private_key_pw = None
try:
private_key_pw = [
k["value"] for k in item["fields"] if k["name"] == pwkeyname
k['value'] for k in item['fields'] if k['name'] == pwkeyname
][0]
logging.debug("Passphrase declared")
logging.debug('Passphrase declared')
except IndexError:
logging.warning('No "%s" field found for item %s', pwkeyname, item["name"])
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"]
'No key "%s" found in item %s - skipping', error.args[0], item['name']
)
try:
private_key_id = [
k["id"]
for k in item["attachments"]
if k["fileName"] == private_key_file
k['id']
for k in item['attachments']
if k['fileName'] == private_key_file
][0]
except IndexError:
logging.warning(
'No attachment called "%s" found for item %s',
private_key_file,
item["name"],
item['name'],
)
continue
logging.debug("Private key ID found")
logging.debug('Private key ID found')
try:
ssh_add(session, item["id"], private_key_id, private_key_pw)
ssh_add(session, item['id'], private_key_id, private_key_pw)
except subprocess.SubprocessError:
logging.warning("Could not add key to the SSH agent")
logging.warning('Could not add key to the SSH agent')
def ssh_add(session: str, item_id: str, key_id: str, key_pw: str = "") -> None:
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
"""
logging.debug("Item ID: %s", item_id)
logging.debug("Key ID: %s", key_id)
logging.debug('Item ID: %s', item_id)
logging.debug('Key ID: %s', key_id)
proc_attachment = subprocess.run(
[
"bw",
"get",
"attachment",
'bw',
'get',
'attachment',
key_id,
"--itemid",
'--itemid',
item_id,
"--raw",
"--session",
'--raw',
'--session',
session,
],
stdout=subprocess.PIPE,
@ -184,20 +226,20 @@ def ssh_add(session: str, item_id: str, key_id: str, key_pw: str = "") -> None:
)
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", "-"],
input=ssh_key.encode("utf-8"),
['ssh-add', '-'],
input=ssh_key,
# Works even if ssh-askpass is not installed
env=envdict,
universal_newlines=False,
universal_newlines=True,
check=True,
)
)
if __name__ == "__main__":
if __name__ == '__main__':
def parse_args() -> argparse.Namespace:
"""
@ -205,34 +247,28 @@ if __name__ == "__main__":
"""
parser = argparse.ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
help="show debug output",
'-d',
'--debug',
action='store_true',
help='show debug output',
)
parser.add_argument(
"-f",
"--foldername",
default="ssh-agent",
help="folder name to use to search for SSH keys",
'-f',
'--foldername',
default='ssh-agent',
help='folder name to use to search for SSH keys',
)
parser.add_argument(
"-c",
"--customfield",
default="private",
help="custom field name where private key filename is stored",
'-c',
'--customfield',
default='private',
help='custom field name where private key filename is stored',
)
parser.add_argument(
"-p",
"--passphrasefield",
default="passphrase",
help="custom field name where key passphrase is stored",
)
parser.add_argument(
"-s",
"--session",
default="",
help="session key of bitwarden",
'-p',
'--passphrasefield',
default='passphrase',
help='custom field name where key passphrase is stored',
)
return parser.parse_args()
@ -252,26 +288,24 @@ if __name__ == "__main__":
logging.basicConfig(level=loglevel)
try:
logging.info("Getting Bitwarden session")
session = get_session(args.session)
logging.debug("Session = %s", session)
logging.info('Getting Bitwarden session')
session = get_session()
logging.debug('Session = %s', session)
logging.info("Getting folder list")
logging.info('Getting folder list')
folder_id = get_folders(session, args.foldername)
logging.info("Getting folder items")
logging.info('Getting folder items')
items = folder_items(session, folder_id)
logging.info("Attempting to add keys to ssh-agent")
logging.info('Attempting to add keys to ssh-agent')
add_ssh_keys(session, items, args.customfield, args.passphrasefield)
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)
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"))
if os.environ.get('SSH_ASKPASS'):
print(os.environ.get('SSH_KEY_PASSPHRASE'))
else:
main()

View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1690933134,
"narHash": "sha256-ab989mN63fQZBFrkk4Q8bYxQCktuHmBIBqUG1jl6/FQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "59cf3f1447cfc75087e7273b04b31e689a8599fb",
"type": "github"
},
"original": {
"id": "flake-parts",
"type": "indirect"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1691464053,
"narHash": "sha256-D21ctOBjr2Y3vOFRXKRoFr6uNBvE8q5jC4RrMxRZXTM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "844ffa82bbe2a2779c86ab3a72ff1b4176cec467",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs-lib": {
"locked": {
"dir": "lib",
"lastModified": 1690881714,
"narHash": "sha256-h/nXluEqdiQHs1oSgkOOWF+j8gcJMWhwnZ9PFabN6q0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9e1960bc196baf6881340d53dccb203a951745a2",
"type": "github"
},
"original": {
"dir": "lib",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,44 +0,0 @@
{
description = "Small python script to load bitwarden-store ssh keys into ssh-agent";
outputs = inputs@{ nixpkgs, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.flake-parts.flakeModules.easyOverlay
];
systems = [ "x86_64-linux" "aarch64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }:
let
package = pkgs.python3Packages.buildPythonPackage {
pname = "bitwarden-ssh-agent";
version = "0.1.2";
src = ./. ;
propagatedBuildInputs = [ pkgs.python3Packages.setuptools pkgs.bitwarden-cli ];
format = "other";
installPhase = ''
mkdir -p $out/bin/
mv bw_add_sshkeys.py $out/bin/bitwarden-ssh-agent
chmod +x $out/bin/bitwarden-ssh-agent
'';
meta = with pkgs.lib; {
description = "Small python script to load bitwarden-store ssh keys into ssh-agent";
homepage = "https://github.com/joaojacome/bitwarden-ssh-agent";
license = licenses.mit;
};
};
in {
overlayAttrs = {
bitwarden-ssh-agent = package;
};
packages.default = package;
devShells.default = pkgs.mkShell {
packages = [
pkgs.bitwarden-cli
(pkgs.python3.withPackages(pkgs: [
pkgs.setuptools
]))
];
};
};
};
}

View file

@ -1,9 +0,0 @@
[mypy]
python_version = 3.10
warn_return_any = True
disallow_untyped_defs = True
disallow_any_unimported = True
no_implicit_optional = True
check_untyped_defs = True
show_error_codes = True
warn_unused_ignores = True

View file

@ -1,2 +0,0 @@
[tool.black]
target-version = ['py310']

View file

@ -1,2 +0,0 @@
[flake8]
max-line-length = 100