Compare commits

..

16 commits

Author SHA1 Message Date
Joao Jacome 6237a3604d
Updated flake lock (#49) 2023-08-08 12:01:51 +00:00
Joao Jacome 6788ba2032
Updated flake lock; moved nix files into the repository root (#47) 2023-07-17 10:51:04 +00:00
Joao Jacome 22c658898d
Removing unused memoize funcion (#46)
* Removing unused memoize funcion
2023-07-17 10:48:29 +00:00
Joao Jacome 3e43873541
Merge pull request #45 from joaojacome/cargomaster
Improved documentation, changed link to archived Bitwarden CLI, fixed subprocess login check
2023-07-17 11:39:02 +01:00
Markus Luisser 039f1940a0
fix: omit subprocess return code check
This avoids running into an Exception when bw (tested with bw 2023.1.0)
returns a non-zero exit code while checking if a bw session already exists.
2023-07-17 11:36:22 +01:00
markus.luisser cdaebb2930
feat: improved doc, outdated link updated 2023-07-17 11:36:22 +01:00
charmparticle fee2a97e30
Fixing linebreaks and wrong encoding when calling ssh-add on Windows (#39)
lyc8503's change that fixes issue #38
2023-06-14 12:58:53 +00:00
Scott Wallace fc541f0e97
Remove deprecated code (#41)
* Tidy typing

* Remove deprecated code (and non-standard dependency)
2023-06-14 12:38:00 +00:00
Joao Jacome 6035f80a11
Setting up PR/push workflows (#34)
* Setting up pull request/push workflows to run Black, MyPy and Flake8
* Fixing formatting
2023-01-14 16:29:39 +00:00
Joao Jacome af79b9e539
Added support to nix flakes (#33) 2023-01-14 15:29:27 +00:00
Joao Jacome 00860658a6
Merge pull request #29 from Weidows/master
fix encoding and add session param
2023-01-14 11:18:23 +00:00
Weidows fc5a20f8d8 updadte README 2022-10-13 22:26:22 +08:00
Weidows c56b7041e4 fix encoding & add session param 2022-10-13 22:23:49 +08:00
Joao Jacome f4d7dd2bf0
Merge pull request #28 from ymage/handle_already_set_variable
Fix for already set SSH_ASKPASS env var
2022-08-22 12:25:20 +01:00
Ymage e186367d5d Fix for already set SSH_ASKPASS env var 2022-08-21 13:11:20 +02:00
Joao Jacome 4908899986
Merge pull request #24 from scottwallacesh/master
Small fixups
2022-05-05 15:07:52 +01:00
11 changed files with 295 additions and 124 deletions

25
.github/workflows/black.yaml vendored Normal file
View file

@ -0,0 +1,25 @@
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

25
.github/workflows/flake8.yaml vendored Normal file
View file

@ -0,0 +1,25 @@
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

25
.github/workflows/mypy.yaml vendored Normal file
View file

@ -0,0 +1,25 @@
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,2 +1,3 @@
.pyenv/
.mypy_cache/
env/

View file

@ -1,9 +1,12 @@
# Bitwarden SSH Agent
## Requirements
* You need to have the [Bitwarden CLI tool](https://github.com/bitwarden/cli) installed and available in the `$PATH` as `bw`.
* 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.
* `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`.
@ -20,11 +23,19 @@ 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)
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.
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)_
* `--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).

View file

@ -8,76 +8,32 @@ import json
import logging
import os
import subprocess
from typing import Any, Callable, Dict, List, Optional
from pkg_resources import parse_version
from typing import Any
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:
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')
logging.debug("Existing Bitwarden session found")
return session
# Check if we're already logged in
proc_logged = subprocess.run(['bw', 'login', '--check', '--quiet'], check=True)
proc_logged = subprocess.run(["bw", "login", "--check", "--quiet"], check=False)
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,
@ -94,50 +50,52 @@ 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:
@ -147,69 +105,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 = None
private_key_pw = ""
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: Optional[str]) -> None:
def ssh_add(session: str, item_id: str, key_id: str, key_pw: 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,
@ -226,20 +184,20 @@ def ssh_add(session: str, item_id: str, key_id: str, key_pw: Optional[str]) -> N
)
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,
["ssh-add", "-"],
input=ssh_key.encode("utf-8"),
# Works even if ssh-askpass is not installed
env=envdict,
universal_newlines=True,
universal_newlines=False,
check=True,
)
)
if __name__ == '__main__':
if __name__ == "__main__":
def parse_args() -> argparse.Namespace:
"""
@ -247,28 +205,34 @@ 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',
"-p",
"--passphrasefield",
default="passphrase",
help="custom field name where key passphrase is stored",
)
parser.add_argument(
"-s",
"--session",
default="",
help="session key of bitwarden",
)
return parser.parse_args()
@ -288,24 +252,26 @@ if __name__ == '__main__':
logging.basicConfig(level=loglevel)
try:
logging.info('Getting Bitwarden session')
session = get_session()
logging.debug('Session = %s', session)
logging.info("Getting Bitwarden session")
session = get_session(args.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'):
print(os.environ.get('SSH_KEY_PASSPHRASE'))
if os.environ.get("SSH_ASKPASS") and os.environ.get(
"SSH_ASKPASS"
) == os.path.realpath(__file__):
print(os.environ.get("SSH_KEY_PASSPHRASE"))
else:
main()

61
flake.lock Normal file
View file

@ -0,0 +1,61 @@
{
"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
}

44
flake.nix Normal file
View file

@ -0,0 +1,44 @@
{
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
]))
];
};
};
};
}

9
mypy.ini Normal file
View file

@ -0,0 +1,9 @@
[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

2
pyproject.toml Normal file
View file

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

2
setup.cfg Normal file
View file

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