Last active
June 1, 2024 13:01
-
-
Save jikamens/15f4b25cec019cb81ddeeee8dacbcfb9 to your computer and use it in GitHub Desktop.
Simple script for backing up your Bitwarden vault using the Bitwarden CLI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# Simple script for backing up your Bitwarden vault using the Bitwarden CLI | |
# | |
# Copyright 2021 Jonathan Kamens <[email protected]> | |
# | |
# This program is free software: you can redistribute it and/or modify it under | |
# the terms of the GNU General Public License as published by the Free Software | |
# Foundation, either version 3 of the License, or (at your option) any later | |
# version. | |
# This program is distributed in the hope that it will be useful, but WITHOUT | |
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | |
# FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
# (https://www.gnu.org/licenses/) for more details. | |
import argparse | |
import getpass | |
import glob | |
import json | |
import os | |
import re | |
import shutil | |
import subprocess | |
from subprocess import CalledProcessError | |
import sys | |
import tempfile | |
def parse_args(): | |
cwd = os.getcwd() | |
parser = argparse.ArgumentParser( | |
description='Back up your Bitwarden data using the Bitwarden CLI') | |
group = parser.add_mutually_exclusive_group() | |
group.add_argument( | |
'--login', action='store_true', default=False, | |
help='Log into Bitwarden before running backup') | |
group.add_argument( | |
'--no-unlock', dest='unlock', action='store_false', default=True, | |
help='Don\'t unlock vault (make sure it\'s already unlocked and ' | |
'you\'ve set $BW_SESSION!)') | |
parser.add_argument( | |
'--2fas', dest='twofas', action='store_true', default=False, | |
help='Fetch 2FA code for login using "twofas-cli", which you need to ' | |
'have installed separately.') | |
parser.add_argument( | |
'--force', action='store_true', default=False, | |
help='Log in even if already logged in') | |
parser.add_argument( | |
'--working-directory', metavar='DIR', dest='directory', action='store', | |
default=cwd, help='Directory to create and store the backup directory ' | |
'in (default current directory)') | |
parser.add_argument( | |
'--no-zip', dest='zip', action='store_false', default=True, | |
help='Don\'t create a ZIP file of the backup (implies --no-gpg and ' | |
'--preserve)') | |
parser.add_argument( | |
'--no-gpg', dest='gpg', action='store_false', default=True, | |
help='Don\'t GPG-encrypt the backup') | |
parser.add_argument( | |
'--preserve', action=argparse.BooleanOptionalAction, default=None, | |
help='Whether to preserve unzipped backup files and intermediate ZIP ' | |
'file (default is False unless --no-zip is specified)') | |
parser.add_argument( | |
'--output-file', dest='output', action='store', | |
default=f'{cwd}/bitwarden', help='Output file (default is ' | |
'"bitwarden.zip.gpg" in current directory; ".zip" or ".zip.gpg" ' | |
'extension will be added if needed)') | |
args = parser.parse_args() | |
if args.zip is False: | |
args.gpg = False | |
if args.preserve is None: | |
args.preserve = not args.zip | |
if args.gpg: | |
if not args.output.endswith('.zip.gpg'): | |
args.output += '.zip.gpg' | |
elif args.zip: | |
if not args.output.endswith('.zip'): | |
args.output += '.zip' | |
return args | |
def main(): | |
args = parse_args() | |
open_vault(args) | |
tmpdir = tempfile.mkdtemp(prefix='bitwarden_backup_', dir=args.directory) | |
try: | |
os.chdir(tmpdir) | |
do_backup(args) | |
finally: | |
os.chdir(args.directory) | |
if not args.preserve: | |
shutil.rmtree(tmpdir) | |
def open_vault(args): | |
unlock = args.unlock and not args.login | |
login = args.login | |
if not (unlock or login): | |
return | |
result = subprocess.run(('bw', 'status'), encoding='us-ascii', | |
capture_output=True) | |
try: | |
parsed = json.loads(result.stdout) | |
except Exception: | |
print(f'Failed to get bitwarden status:\n' | |
f'{result.stdout}{result.stderr}' | |
f'Assuming login needed.') | |
login = True | |
status = parsed['status'] | |
email = None | |
if status in ('unlocked', 'locked'): | |
email = parsed['userEmail'] | |
if login: | |
if args.force: | |
# Test case: Log into CLI outside of this script, set | |
# BW_SESSION environment variable, run with `--login --force` | |
subprocess.run(('bw', 'logout'), encoding='us-ascii', | |
capture_output=True, check=True) | |
unlock = False | |
else: | |
# Test case: Log into CLI outside of this script, set | |
# BW_SESSION environment variable, run with `--login` | |
sys.exit( | |
f'You are already logged in as {email}.\n' | |
f'Specify --force to log in again.') | |
elif status == 'unlocked': | |
# Test case: Log into CLI outside of this script, set BW_SESSION | |
# environment variable, run with no arguments. | |
print('Vault is already unlocked, not unlocking or logging in.') | |
return | |
elif status == 'unauthenticated': | |
if not login: | |
print('Not logged into Bitwarden, doing login.') | |
login = True | |
unlock = False | |
else: | |
sys.exit(f'Unrecognized bw CLI status: {status}') | |
while unlock: | |
# Test case: Log into CLI outside of this script, make sure BW_SESSION | |
# environment variable is not set, run with no arguments. | |
password = getpass.getpass(f'Master password for {email}: ') | |
result = subprocess.run(('bw', 'unlock'), encoding='us-ascii', | |
input=password + '\n', capture_output=True) | |
if result.returncode: | |
# Test case: Loginto CLI outside of this script, make sure | |
# BW_SESSION environment variable is not set, run with no | |
# arguments, enter incorrect master password. | |
print(f'{result.stdout}{result.stderr}\n', end='') | |
response = input( | |
'Unlock failed. Unlock (a)gain or (l)og out and log back in? ') | |
if response == 'a': | |
continue | |
elif response == 'l': | |
login = True | |
result = subprocess.run(('bw', 'logout'), | |
encoding='us-ascii') | |
break | |
else: | |
raise Exception('Invalid response.') | |
else: | |
output = result.stdout | |
break | |
if login: | |
cmd = ['bw', 'login'] | |
if args.twofas: | |
code = subprocess.check_output( | |
('twofas-cli', 'get', '--domain', 'https://bitwarden.com'), | |
encoding='us-ascii').strip() | |
cmd.extend(('--code', code)) | |
if email: | |
cmd.extend((email,)) | |
output = subprocess.check_output(cmd, encoding='us-ascii') | |
match = re.search(r'(BW_SESSION)="(.*)"', output) | |
if not match: | |
sys.exit(f'Could not find session key in bw output:\n{output}') | |
os.environ[match.group(1)] = match.group(2) | |
def do_backup(args): | |
os.mkdir('attachments') | |
subprocess.check_call(('bw', 'sync')) | |
items = bw_list('items') | |
folders = bw_list('folders') | |
bw_list('collections') | |
bw_list('organizations') | |
for item in items.values(): | |
if 'attachments' not in item: | |
continue | |
attachment_dir = 'attachments' | |
if 'folderId' in item: | |
attachment_dir += '/' + folders[item['folderId']]['name'] | |
attachment_dir += '/' + item['name'] | |
for attachment in item['attachments']: | |
attachment_path = attachment_dir + '/' + item['name'] + '/' + \ | |
attachment['fileName'] | |
if not os.path.exists(os.path.dirname(attachment_path)): | |
os.makedirs(os.path.dirname(attachment_path)) | |
try: | |
# The Bitwarden CLI snap doesn't have permission to access the | |
# filesystem, so we need to tell it to send output to stdout | |
# and redirect the output to where we want it to go, i.e., we | |
# can't use "--output filename". According to the CLI | |
# documentation, when you specify "--raw" without "--output", | |
# output is sent to stdout. | |
cmd = ('bw', 'get', 'attachment', attachment['id'], | |
'--itemid', item['id'], '--raw') | |
with open(attachment_path, "w") as output_handle: | |
subprocess.check_call(cmd, stdout=output_handle) | |
except CalledProcessError: | |
# I put in this error handling when the Bitwarden CLI was | |
# encountering errors downloading some attachments. This turned | |
# out to be because I was using an old version of the CLI which | |
# was incompatible with some recent server-side changes, so | |
# this code may no longer be nececessary, but it doesn't harm | |
# anything, so I'm leaving it in just in case a similar problem | |
# occurs in the future. | |
answer = input( | |
f'Attachment {attachment_path} download failed ' | |
f'({" ".join(cmd)}). Download by hand? ') | |
if answer.lower().startswith('y'): | |
input('Save attachment and then hit Enter: ') | |
if not os.path.exists(attachment_path): | |
print('Attachment not downloaded!') | |
raise | |
else: | |
raise | |
if args.zip: | |
zipfile1 = 'bitwarden.zip' if args.gpg else args.output | |
subprocess.check_call(['zip', '-r', zipfile1] + glob.glob('*')) | |
if args.gpg: | |
subprocess.check_call( | |
('gpg', '-o', args.output, '--encrypt', zipfile1)) | |
print(f'Encrypted backup saved as {args.output}') | |
else: | |
print(f'ZIP backup saved as {args.output}') | |
else: | |
print(f'Backup saved in {os.getcwd()}') | |
def bw_list(object_type): | |
blob = subprocess.check_output(('bw', 'list', object_type)) | |
objects = json.loads(blob) | |
with open('{}.json'.format(object_type), 'w') as f: | |
json.dump(objects, f, indent=2) | |
return {o['id']: o for o in objects} | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment