Skip to content

Instantly share code, notes, and snippets.

@jikamens
Created June 26, 2023 15:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jikamens/15f4b25cec019cb81ddeeee8dacbcfb9 to your computer and use it in GitHub Desktop.
Save jikamens/15f4b25cec019cb81ddeeee8dacbcfb9 to your computer and use it in GitHub Desktop.
Simple script for backing up your Bitwarden vault using the Bitwarden CLI
#!/usr/bin/env python3
# Simple script for backing up your Bitwarden vault using the Bitwarden CLI
#
# Copyright 2021 Jonathan Kamens <jik@kamens.us>
#
# 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(
'--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']
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:
output = subprocess.check_output(('bw', 'login'), 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