Skip to content

Instantly share code, notes, and snippets.

@ignlg
Forked from mpalet/bw_export_kp.py
Last active March 31, 2020 18:20
Show Gist options
  • Save ignlg/0a349712a973cb94be67c8c4a3e3a196 to your computer and use it in GitHub Desktop.
Save ignlg/0a349712a973cb94be67c8c4a3e3a196 to your computer and use it in GitHub Desktop.
Export Bitwarden to KeePass 2 XML format, with custom banned fields to clean things up
#!python
"""
Exports a Bitwarden database into a KeePass file (kdbx) including file attachments, custom fields and folder structure.
It can also export an unencrypted XML KeePass file conforming to KeePass 2 XML format.
It requires keepassxc-cli, if not available it can still export KeePass 2 XML
- https://github.com/keepassxreboot/keepassxc
Usage: bw_export_kp.py [-h] [-x] [-d] [-bw-password None] [-kee-password None]
bw_user output_file
positional arguments:
bw_user
output_file
optional arguments:
-h, --help show this help message and exit
-x, --xml-output saves an UNENCRYPTED KeePass 2 XML file
-d, --diff-pass different passwords for Bitwarden and KeePass file
-bw-password None Bitwarden password (prompted if not provided)
-kee-password None KeePass password (prompted if not provided)
References:
- Bitwarden CLI: https://help.bitwarden.com/article/cli/
- KeePass 2 XML: https://github.com/keepassxreboot/keepassxc-specs/blob/master/kdbx-xml/rfc.txt
"""
from __future__ import print_function
import os
import getpass
import xmltodict
import uuid
import json
import subprocess
import base64
import tempfile
import contextlib
import shutil
import errno
import sys
found = []
banned = ['02U',
'02W',
'02Y',
'030',
'0_cart_quantity_select',
'1_cart_quantity_select',
'2_cart_quantity_select',
'2b54dd7995c52288df0bc7c36e7f1725840e3122',
'3_cart_quantity_select',
'4_cart_quantity_select',
'5_cart_quantity_select',
'6035d4db3b4cbb3e34acc605977169d7cefb1d52',
'_fmu.un._0.e',
'_fmu.uni-._0.l',
'_fmu.uni-regi._0.c',
'_fmu.uni-register-a._0.a',
'_fmu.uni-register-aut._0.a',
'_fmu.uni-register-auth._0.a',
'_fmu.uni-register-authcom._0.a',
'_fmu.uni-register-me._0.m',
'_fmu.uni-register-mem._0.m',
'_fmu.uni-register-memb._0.m',
'_fmu.uni-register-p._0.p',
'_fmu.uni-register-pa._0.pa',
'_fmu.uni-register-ph._0.p',
'_fmu.uni-register-pho._0.p',
'_fmu.uni-register._0.t',
'_fmu.uni._0.f',
'accept_terms',
'accept_terms_use',
'AcceptPrivacyPolicy',
'account.Password2',
'aceptado',
'acepto',
'acepto1',
'acepto2',
'aceptoCond',
'aceptocookies',
'acpo',
'action',
'address1',
'address[city]',
'address[country]',
'address[name]',
'address[postal_code]',
'address[province]',
'address[street]',
'address[surname]',
'admin_color',
'age',
'agree',
'agree_tos',
'allow_email',
'anno',
'answer1',
'apellido1',
'apellido2',
'approve_terms',
'authCompanyStateInput',
'AuthenticationRequestDto.EmailAddress',
'AuthenticationRequestDto.Password',
'ayoNac',
'b-entrar',
'B1',
'bbsbbstopics',
'billing[address]',
'billing[city]',
'billing[name]',
'billing[phone]',
'billing[state]',
'billing[zip]',
'billing_zip',
'BirthDay',
'birthday.day',
'birthday.month',
'birthday.year',
'BirthYear',
'blog_public',
'borrower_address',
'borrower_city',
'borrower_country',
'borrower_dateofbirth',
'borrower_email_repeat',
'borrower_firstname',
'borrower_nif',
'borrower_phone',
'borrower_sex',
'borrower_state',
'borrower_streetnumber',
'borrower_surname',
'borrower_zipcode',
'boton',
'btn_continue',
'btnChangpassword',
'btnLogin',
'business',
'button',
'callingCode',
'cancelbutton',
'candidate[address]',
'candidate[name]',
'candidate[time_zone]',
'Captcha',
'captchaAnswer',
'cargo_password2',
'carpeta',
'cart_quantity[]',
'CestaForm[aceptar]',
'CestaForm[apellidos]',
'CestaForm[codigo_postal_empresa]',
'CestaForm[confirmar_contrasena]',
'CestaForm[direccion_empresa]',
'CestaForm[nombre]',
'CestaForm[poblacion_empresa]',
'CestaForm[provincia_empresa]',
'CestaForm[telefono]',
'cgv',
'changepassword',
'check_password',
'china_payment_selection',
'City',
'city',
'clave',
'clicked_element',
'co',
'cod_pos_cas',
'commentshours',
'commercialInfo',
'commit',
'company name',
'company',
'company-name',
'company_size',
'companyCountry',
'condiciones',
'condiciones1step',
'conditions',
'confirm',
'confirm-password',
'confirm_email_address',
'confirmaEmail',
'confirmaPass',
'confirmation',
'confirmationemail',
'ConfirmEmail',
'ConfirmPassword',
'confirmpassword',
'confirmPassword',
'contrasenha_repeat',
'cookielength',
'cookieuser',
'country',
'Country',
'country_code',
'country_id',
'country_key',
'country_name',
'country_prefix_phone_1',
'countryId',
'CountryID',
'cp',
'createuser',
'Credentials.UsernameRepeat',
'credit limit',
'csdb',
'ctl00$bCPH$tabContainer$loginPanel$loginControl$LoginButton',
'ctl00$bCPH$tabContainer$loginPanel$loginControl$Password',
'ctl00$bCPH$tabContainer$newUserPanel$createUserWizard$__CustomNav0$StepNextButtonButton',
'ctl00$bCPH$tabContainer$newUserPanel$createUserWizard$CreateUserStepContainer$ConfirmEmail',
'ctl00$bCPH$tabContainer$newUserPanel$createUserWizard$CreateUserStepContainer$ConfirmPassword',
'ctl00$bCPH$tabContainer$newUserPanel$createUserWizard$CreateUserStepContainer$Email',
'ctl00$ContentPlaceHolder1$uc_RegisterForm$',
'ctl00$ContentPlaceHolder1$uc_RegisterForm$btn_login',
'ctl00$ContentPlaceHolder1$uc_RegisterForm$btn_register',
'ctl00$ContentPlaceHolder1$uc_RegisterForm$frm_remember_me',
'ctl00$ContentPlaceHolder1$uc_RegisterForm$frm_remember_me_new_user',
'ctl00$ContentPlaceHolderOficinaVirtual$btCancelar',
'ctl00$ContentPlaceHolderOficinaVirtual$btEnviar',
'ctl00$ctl00$ctl00$ctl00$base_content$web_base_content$home_content$page_content_left$ctl01$CreateUserButton',
'ctl00$ctl00$ctl00$ctl00$base_content$web_base_content$home_content$page_content_left$ctl01$Email',
'ctl00$ctl00$ctl00$ctl00$base_content$web_base_content$home_content$page_content_left$ctl01$FirstName',
'ctl00$ctl00$ctl00$ctl00$base_content$web_base_content$home_content$page_content_left$ctl01$LastName',
'ctl00$ctl00$ctl00$ctl00$base_content$web_base_content$home_content$page_content_left$ctl01$SignupPWord',
'ctl00$PagePlaceHolder$btnPreRegistro',
'ctl00$PagePlaceHolder$btnReturn',
'currency',
'custom_fields[custom2]',
'customer_firstname',
'customer_lastname',
'customer_privacy',
'customerName',
'CustomerType',
'data.Password2',
'DataPrivacyAcceptedCheckBox',
'dataProtectionSpecific',
'days',
'default_newsletters',
'delivery_option[0]',
'department',
'departureAirportName',
'description',
'diaNac',
'direccion',
'DistributionID',
'DNI',
'dni',
'dob',
'dob_Day',
'dob_Month',
'dob_Year',
'doLogin',
'donde',
'doOrder',
'doRegister',
'doReset',
'dst',
'email1',
'email2',
'email_confirm',
'email_newsletter3',
'email_repeat',
'email_verify',
'emailAsAliasBuff',
'EmailB',
'emailConfirm',
'emailconfirm',
'emailConfirmation',
'emailok',
'EMAILTYPE',
'enterprise-size',
'entra',
'Enviar',
'enviar',
'event_address',
'event_city',
'event_name',
'event_start_date_time',
'event_start_date_time_date',
'event_start_date_time_time',
'event_url',
'event_venue_capacity',
'event_venue_name',
'f-a94e32ce9d2f414b4c3b-email',
'f__camp-llocs_poi',
'f__camp-llocs_poi1',
'f__camp-llocs_poi2',
'f__camp-llocs_poi_d',
'f__checkbox__1',
'f__checkbox__3',
'f__checkbox__4',
'f__checkbox__5',
'f__checkbox__8',
'f_submit_login',
'facebookLink',
'fechaNac',
'first name',
'first',
'first-name',
'first_name',
'first_name-4',
'firstName',
'FirstName',
'firstname',
'FiscalCode',
'flight-search-type-option',
'fname',
'form1:ape1PER',
'form1:ape2PER',
'form1:confirmacionEmail',
'form1:confirmPass',
'form1:documentoPER',
'form1:email',
'form1:idioma',
'form1:j_id_jsp_1264618786_582pc5',
'form1:nombrePER',
'form1:nombreUsr',
'form1:nombreViaPER',
'form1:numeroViaPER',
'form1:pisoPER',
'form1:portalPER',
'form1:preguntaRecordatorio',
'form1:puertaPER',
'form1:respuestaRecordatorio',
'form1:tipoViaFacturaPER',
'form1:tipoViaPER',
'form1:usoCuenta',
'form_address_key',
'free',
'fsurname',
'fttkhcjf',
'full_name',
'fullname',
'gamer_account',
'gender',
'Github',
'GmailAddress',
'Go',
'googleLink',
'group1',
'group[10469][1]',
'hide_password',
'hide_register[password][second]',
'http_passwd',
'http_passwdConfirm',
'i_agree_check',
'iagree',
'iagreebutton',
'id_country',
'id_country_invoice',
'id_gender',
'id_state',
'id_state_invoice',
'idioma',
'ignoreEmailDup',
'im_id',
'image_type',
'image_verify_code',
'indexbbstopics',
'indexlatestadded',
'indexlatestcomments',
'indexlatestparties',
'indexlatestreleased',
'indexojnews',
'indexoneliner',
'indextopglops',
'indextopkeops',
'indextopprods',
'indexwatchlist',
'india_payment_selection',
'inf_field_City',
'inf_field_Email',
'inf_field_FirstName',
'inf_field_LastName',
'input_10',
'input_12',
'input_14',
'input_16.1',
'input_1_2',
'input_2.3',
'input_2.6',
'input_6.6',
'input_9.1',
'inputEmail',
'inputLanguage',
'inputLocation',
'inputPass2',
'interval',
'is_subscribed',
'itemsPerPageInTable',
'j_id105:checkboxAceptarCondiciones',
'j_id105:checkboxAceptarLOPD',
'j_id105:j_id253',
'j_id105:pais',
'j_id105:resPartnerAddresscif',
'j_id105:resPartnerAddressCity',
'j_id105:resPartnerAddresspasswordConfirma',
'j_id105:resPartnerAddressphone',
'j_id105:resPartnerAddressProvincia',
'j_id105:resPartnerAddressstreet',
'j_id105:resPartnerAddresszip',
'j_id105:resPartnerEmail',
'j_id105:resPartnername',
'j_id105:resPartnersurname',
'j_id105:resPartnersurname2',
'jabber',
'job_title_level',
'jobTitle',
'jrlsolej',
'keep_logged_in',
'keepMeSignInOption',
'keyval',
'keywords',
'lang',
'lang-chooser',
'language',
'language-selector',
'LanguageSelect',
'last name',
'last',
'last-name',
'last_name',
'last_name-4',
'lastname',
'LastName',
'lastName',
'LinkedIn',
'lname',
'lnshdven',
'locale',
'localidad',
'location',
'location_check_1',
'location_check_2',
'log_nombre',
'log_persistent',
'log_pwd',
'login',
'Login',
'LOGIN',
'login_submit_dummy',
'login_threadless',
'loginButton',
'loginForm.remember',
'LoginForm[email]',
'LoginForm[password]',
'logonId',
'logonPasswordVerify',
'lugar',
'lugar_instalacion',
'mail',
'mailinglist',
'member ID (additional)',
'member name',
'member since',
'member[password_confirmation]',
'membernext',
'mesNac',
'mobile',
'mobile_country',
'mobile_intl',
'mobilePhone',
'months',
'móvil',
'n_pass2',
'nationalId',
'nationalIdentificationNumber',
'nationality',
'new_password',
'new_password1',
'new_password2',
'NewPasswordConfirm',
'newPinCheck',
'newsletter',
'newsletters[317]',
'NewUserContactDetails.ConfirmPassword',
'NewUserContactDetails.Email',
'NewUserContactDetails.FirstName',
'NewUserContactDetails.LastName',
'NewUserLogin.NewEmail',
'nick_name',
'nickname',
'no_name',
'nombre',
'NOMBRE',
'nombreVia',
'numDoc',
'objecttype',
'op',
'options',
'options[adminemail]',
'original email',
'original mail',
'p2',
'pais',
'pApellido',
'pasPassw_2',
'pass',
'pass1-text',
'PASS2',
'pass2',
'pass[pass2]',
'passwd2',
'PasswdAgain',
'password-confirm',
'Password2',
'password2',
'password_2',
'password_again',
'password_answer',
'password_confirm',
'password_confirmar',
'password_confirmation',
'password_confirmation_again',
'password_question',
'password_repeat',
'password_two',
'password_verify',
'passwordAgain',
'passwordCheck',
'PasswordConf',
'passwordconfirm',
'passwordConfirm',
'passwordConfirmation',
'passwordResetRequest[password][second]',
'passwrd',
'passwrd1',
'passwrd2',
'payment_type',
'PC759$AddressCtl$tb_AddressLine',
'PC759$AddressCtl$tb_CityUK',
'PC759$AddressCtl$tb_ZipUK',
'PC759$btnRegRequest',
'PC759$dpBirthDate',
'PC759$txtConfirmPwdR',
'PC759$txtEmailRR',
'PC759$txtFirstName',
'PC759$txtLastName',
'PC759$txtPhone',
'persistent',
'PersistentCookie',
'personTitle',
'phone',
'Phone',
'phone1',
'phone_1',
'phone_intl',
'phone_mobile',
'phone_number',
'phoneNumber',
'phpcan_action',
'piso',
'poblacion',
'policies',
'policy',
'portal',
'postal_code',
'postcode',
'prefix_phone_1',
'primaryRole',
'primaryUse',
'privacy',
'prodlistprods',
'profile.contactme',
'Profile.FirstName',
'profile.gender',
'Profile.Gender',
'Profile.LastName',
'profile_perfil_p[field_primer_apellido][und][0][value]',
'profile_perfil_p[field_usuario_nombre][und][0][value]',
'profileFirstName',
'profileLastName',
'profilephone_number',
'promotion_code',
'provideProducts',
'provider',
'province',
'provincia',
'pt[]',
'puerta',
'pw1',
'pw2',
'pwd',
'qa_answer',
'qs',
'quantity_1142_0_0_0',
'quantity_2668_609_0_0',
'quantity_3800_0_0_0',
'quantity_3804_979_0_0',
'quantity_5081_0_0_0',
'quantity_744_0_0_0',
'query',
'QuestionCode1',
'r_address/1.company_name',
'radioReConsider',
'recaptcha_response_field',
'recognize',
'RecoveryPhoneNumber',
'reenter_email',
'reenter_password',
'regenerar',
'region_id',
'register',
'register.birth.day',
'register.birth.month',
'register.birth.year',
'register_vv[code]',
'register_vv[q][1]',
'register_vv[q][337]',
'register_vv[q][358]',
'registerambit',
'registercarrera',
'registercheck',
'registercognoms',
'registerform.deliverycountry',
'registerform.deliverytitle',
'registerform.paymentaddress1',
'registerform.paymentcountry',
'registerform.paymentdayphone',
'registerform.paymentfirstname',
'registerform.paymentmobilephone',
'registerform.paymentsurname',
'registerform.paymenttitle',
'registerform.paymenttown',
'registerform.paymentzip',
'registerform.repeat',
'registerform.selectasdelivery',
'registernom',
'registerpass',
'registeruser',
'registrarse',
'regSubmit',
'remember',
'remember_me',
'rememberme',
'rememberMe',
'rememberMe:checkbox',
'rememberPassword',
'reminder answer',
'reminder question',
'repassword',
'repeat_user_password',
'repeatEmail',
'repeatPassword',
'RetypePassword',
'ringkosk',
'rNewPassword',
'role',
'rsa',
'sa',
'sApellido',
'savePassword',
'search',
'searcharea',
'searchGranularity',
'searchin',
'searchprods',
'searchType',
'searchwords',
'secretQuestion',
'Security questions',
'segments_select[]',
'select-type',
'select_language',
'selector-currency',
'selIsClass',
'selIsWantFace',
'selIsWantTextChat',
'selX3',
'selX3a',
'selX4',
'selX6',
'selX6a',
'selX6b',
'send',
'send_user_notification',
'sex',
'sexo',
'sgnBt',
'ShippingCountryID',
'signIn',
'signin',
'SignUp',
'signup-name',
'signup[terms_of_service]',
'signup[username]',
'signup_email_mask',
'signup_name',
'signup_password_mask',
'SignupForm[firstName]',
'SignupForm[lastName]',
'skype',
'slengpung',
'spareEmail',
'spree_user[password_confirmation]',
'sq',
'ssh.Password2',
'state',
'state_tf',
'street',
'Street',
'street_address',
'styleid',
'subdomain',
'Submit',
'submit',
'submit-30887',
'submit-button',
'submit1',
'submit_button',
'submit_search',
'submitAccount',
'submitAddDiscount',
'submitbutton',
'submitContext',
'SubmitLogin',
'submitNewsletter',
'subscribe',
'subscribe[]',
'subscribe_to_email_communication',
'subscribed_to_newsletter',
'surname',
'surnames',
'swapSize',
'telefono',
'telefono2',
'telekom_sitereportbundle_user[password][Repeat]',
'telekom_sitereportbundle_user[whoWill]',
'Template$FormControl$Button1',
'Template$FormControl$txtCodigoActual',
'Template$FormControl$txtNuevoCodigo',
'term',
'terms',
'terms[term]',
'terms_of_use',
'termsAgree',
'termsChecked',
'TermsOfService',
'TestForApplications',
'time_zone',
'timezone',
'timezoneoffset',
'tip_doc',
'tipoDoc',
'tipoVia',
'title_key',
'titulo',
'topicposts',
'tos',
'tos_agree',
'tosPrivacy',
'toua_consent',
'Twitter',
'txtEmail2',
'type_account',
'unlimited',
'up_email',
'up_password',
'up_password_',
'up_user',
'updates',
'usage-type',
'user-type',
'user[account_type]',
'user[country]',
'user[country_code]',
'user[email]',
'user[email_confirmation]',
'user[email_volume]',
'user[first_name]',
'user[firstname]',
'user[full_name]',
'user[last_name]',
'user[lastname]',
'user[location]',
'user[name]',
'user[organisation][name]',
'user[over_13_and_accept_terms_and_conditions]',
'user[password]',
'user[password_confirm]',
'user[password_confirmation]',
'user[registration_company]',
'user[registration_role]',
'user[role]',
'user[terms_of_service]',
'user[time_zone]',
'user[weekly_newsletter]',
'user_email',
'user_email2',
'user_login',
'user_login-4',
'user_name',
'user_pass2',
'user_password-4',
'user_password_confirmation',
'user_registered',
'userAcceptsTermsOfService',
'usercomments',
'UserDto.UserCredential.ChallengeAnswer',
'UserDto.UserCredential.ChallengeQuestionId',
'UserDto.UserCredential.ConfirmPassword',
'usergroups',
'userlistusers',
'userlogos',
'usernfos',
'userparties',
'userPrincipal##alias',
'userPrincipal##confirmEmail',
'userPrincipal##confirmPassword',
'userPrincipal##secretAnswer',
'userPrincipal##secretQuestion',
'userPrincipal##spamBlocker',
'userprods',
'userrulez',
'userscreenshots',
'usersucks',
'usuarios[apodo]',
'usuarios[nombre]',
'usuarios[sexo]',
'vb_login_password',
'Vencemento',
'verify',
'vp',
'web_user[password_confirmation]',
'weblog_title',
'wp-submit',
'wpLoginattempt',
'wpLoginAttempt',
'wpMailmypassword',
'wpRemember',
'wt-msisdn',
'years',
'yt0',
'yt1',
'za-signup-btn',
'zero',
'zip',
'zipcode',
'Zipcode',
'zone_id',
'zxdemo', 'mes', 'card_expiration_year', 'card_expiration_month', 'num', 'card_number', 'mes', 'birth', 'birth date', 'birth_date', 'card_code', 'password', 'user_password', 'passwd', 'contrasena', 'login_passwd', 'cpassword', 'login_password', 'admin_password', 'username', 'usuario', 'Passwd']
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def is_tool(name):
"""Check whether `name` is on PATH and marked as executable."""
# from whichcraft import which
from shutil import which
return which(name) is not None
def static_vars(**kwargs):
"""
Decorates a function with static variables and initializes them.
Usage:
Add before function declaration
@static_vars(var1=value, var2=value, ...)
"""
def decorate(func):
for k in kwargs:
setattr(func, k, kwargs[k])
return func
return decorate
@contextlib.contextmanager
def write_open(filename=None):
if filename and filename != '-':
dirname, basename = os.path.split(filename)
temp, tempPath = tempfile.mkstemp(prefix=basename, dir=dirname)
fh = open(temp, 'w')
else:
fh = sys.stdout
try:
yield fh
finally:
if fh is not sys.stdout:
fh.close()
shutil.move(tempPath, filename)
def safe_move(src, dst):
"""Rename a file from ``src`` to ``dst``.
* Moves must be atomic. ``shutil.move()`` is not atomic.
Note that multiple threads may try to write to the cache at once,
so atomicity is required to ensure the serving on one thread doesn't
pick up a partially saved image from another thread.
* Moves must work across filesystems. Often temp directories and the
cache directories live on different filesystems. ``os.rename()`` can
throw errors if run across filesystems.
So we try ``os.rename()``, but if we detect a cross-filesystem copy, we
switch to ``shutil.move()`` with some wrappers to make it atomic.
"""
try:
os.rename(src, dst)
except OSError as err:
if err.errno == errno.EXDEV:
# Generate a unique ID, and copy `<src>` to the target directory
# with a temporary name `<dst>.<ID>.tmp`. Because we're copying
# across a filesystem boundary, this initial copy may not be
# atomic. We intersperse a random UUID so if different processes
# are copying into `<dst>`, they don't overlap in their tmp copies.
copy_id = uuid.uuid4()
tmp_dst = "%s.%s.tmp" % (dst, copy_id)
shutil.copyfile(src, tmp_dst)
# Then do an atomic rename onto the new name, and clean up the
# source image.
os.rename(tmp_dst, dst)
os.unlink(src)
else:
raise
def get_uuid(name):
"""
Computes the UUID of the given string as required by KeePass XML standard
https://github.com/keepassxreboot/keepassxc-specs/blob/master/kdbx-xml/rfc.txt
"""
uid = uuid.uuid5(uuid.NAMESPACE_DNS, name)
return base64.b64encode(uid.bytes).decode("utf-8")
def get_folder(f):
"""
Returns a dict of the input folder JSON structure returned by Bitwarden.
"""
return dict(UUID=get_uuid(f['name']),
Name=f['name'])
def get_protected_value(v):
"""
Returns a Value element that is "memory protected" in KeePass
(useful for Passwords and sensitive custom fields/strings).
"""
return {'#text': v, '@ProtectInMemory': 'True'}
def get_fields(subitem, protected=[]):
"""
Returns the components of subitem as a fields array,
protecting the items in protected list
"""
fields = []
for k, v in subitem.items():
if v is not None:
# check if it's protected
k = get_correct_name(k)
if k in protected:
v = get_protected_value(v)
fields.append(dict(Key=k, Value=v))
return fields
def get_correct_name(name):
lower = name.lower()
if lower in ['username', 'usuario', 'alias']:
name = 'Alias'
if lower in ['name', 'accountname']:
name = 'Name'
elif lower in ['email', 'user_email', 'email_address', 'login_email']:
name = 'Email'
elif lower in ['address']:
name = 'Address'
elif lower in ['numero', 'number', ]:
name = 'Number'
elif lower in ['bank name', ]:
name = 'BankName'
elif lower in ['name on account', ]:
name = 'NameOnAccount'
elif lower in ['cardholder name', 'cardholderName']:
name = 'CardholderName'
elif lower in ['verification number', ]:
name = 'VerificationNumber'
elif lower in ['website', 'url']:
name = 'Website'
elif lower in ['type', 'brand']:
name = 'Brand'
elif lower == 'pubilc key' or lower == 'public key':
name = 'PublicKey'
elif lower == 'pirvate key' or lower == 'private key':
name = 'PrivateKey'
return name
@static_vars(binary_id=0, binaries=[])
def get_entry(e):
"""
Returns a dict of the input entry (item from Bitwarden)
Parses the title, username, password, urls, notes, and custom fields.
"""
fields = []
done = []
# Parse custom fields, protecting as necessary
if 'fields' in e:
for f in e['fields']:
if f['name'] not in banned and f['value'] is not None:
# get value
value = f['value']
# get key
key = get_correct_name(f['name'])
if key not in done:
done.append(key)
found.append(key)
# if protected?
if f['type'] == 1 or key in ['PrivateKey', 'VerificationNumber', 'storePassword', 'PIN']:
value = get_protected_value(value)
# put together
fields.append(dict(Key=key, Value=value))
# default values
urls = ''
username, password = '', ''
notes = e['notes'] if e['notes'] is not None else ''
# read username, password, and url if a login item
if 'login' in e:
login = e['login']
if 'uris' in login:
urls = [u['uri'] for u in login['uris']]
urls = ','.join(urls)
# get username and password
username = login['username']
password = login['password']
# add totop to fields as protected
fields.append(dict(Key='totp',
Value=get_protected_value(login['totp'])))
# Parse Card items
if 'card' in e:
# Make number a protected field
fields.extend(get_fields(e['card'], protected=[
'number', 'Number', 'VerificationNumber', 'verification number', 'PIN', 'code', 'Code']))
# Parse Identity items
if 'identity' in e:
fields.extend(get_fields(e['identity']))
# Parse attachments
attachments = []
if 'attachments' in e:
for a in e['attachments']:
if a['id'] is not None:
# append attachment reference
attachments.append(
dict(
Key=a['fileName'],
Value={'@Ref': get_entry.binary_id}
)
)
# add binary data to function static list and update static counter
att = get_bw_attachment(a['id'], e['id'])
get_entry.binaries.append(
dict({'@ID': get_entry.binary_id,
'@Compressed': 'False', '#text': att})
)
get_entry.binary_id += 1
# Check it's not None
username = username or ''
password = password or ''
# assemble the entry into a dict with a UUID
entry = dict(UUID=get_uuid(e['name']),
String=[dict(Key='Title', Value=e['name']),
dict(Key='UserName', Value=username),
dict(Key='Password', Value=get_protected_value(password)),
dict(Key='URL', Value=urls),
dict(Key='Notes', Value=notes)
] + fields)
if (attachments):
entry.update(dict(Binary=attachments))
return entry
def get_cmd_output(cmd):
"""
Returns the output of the given command
"""
status, output = subprocess.getstatusoutput(cmd)
if status != 0:
eprint("Error running command:", cmd)
raise Exception
return output
def bw_logout():
"""
Bitwarden logout
"""
cmd = 'bw logout --raw'
subprocess.run(cmd, shell=True, capture_output=True)
def get_bw_data():
"""
Gets the folders and items from Bitwarden CLI
"""
# get folders
cmd = 'bw list folders --session '+main.bw_session
folders = json.loads(get_cmd_output(cmd))
# get items
cmd = 'bw list items --session '+main.bw_session
items = json.loads(get_cmd_output(cmd))
return folders, items
def secure_delete(path, passes=1):
"""
Safely delete a file by overwriting it with random data
"""
with open(path, "ba+") as delfile:
length = delfile.tell()
for i in range(passes):
delfile.seek(0)
delfile.write(os.urandom(length))
os.remove(path)
def get_bw_attachment(id, itemid):
"""
Gets an attachment from Bitwarden CLI
"""
with tempfile.TemporaryDirectory() as tmpdir:
cmd = 'bw get attachment --itemid '+itemid+' '+id + \
' --output '+tmpdir+'/ --raw --session '+main.bw_session
path = get_cmd_output(cmd)
if not os.path.isfile(path):
eprint("Error downloading attachment:", id)
raise Exception
with open(path, "rb") as f:
encoded_file = base64.b64encode(f.read()).decode("utf-8")
secure_delete(path)
return encoded_file
@static_vars(bw_session='')
def main(bw_user, output_file,
xml_output: ('saves an UNENCRYPTED KeePass 2 XML file', 'flag', 'x'),
diff_pass: ('different passwords for Bitwarden and KeePass file', 'flag', 'd'),
bw_password: ('Bitwarden password (prompted if not provided)', 'option') = None,
kee_password: (
'KeePass password (prompted if not provided)', 'option') = None
):
"""
Main function
"""
if not is_tool('bw'):
eprint("Bitwarden cli not found")
raise Exception
# Log out any existing session
bw_logout()
# Bitwarden login
if bw_password is None:
bw_password = getpass.getpass(prompt='Bitwarden password: ')
cmd = 'bw --raw login '+str(bw_user)
res = subprocess.run(cmd, input=bytearray(
bw_password, 'utf-8'), shell=True, capture_output=True)
if res.returncode != 0:
eprint("Wrong password")
raise SystemExit
main.bw_session = res.stdout.decode()
del res # delete result object which has the password as a cmd argument
# set keepass password
if kee_password is None:
if diff_pass:
kee_password = getpass.getpass(prompt='KeePass password: ')
else:
kee_password = bw_password
del bw_password
# get data from bw
bw_folders, bw_items = get_bw_data()
# parse all entries
entries = [get_entry(e) for e in bw_items]
# loop over folders
# bw_folders = d['folders']
folders = []
root_entries = []
for f in bw_folders:
# parse the folder
folder = get_folder(f)
folder_id = f['id']
# loop on entries in this folder
folder_entries = []
for entry, item in zip(entries, bw_items):
if item['folderId'] == folder_id:
folder_entries.append(entry)
# NoFolder (with None id)
if folder_id is None:
root_entries = folder_entries
# Normal folder
else:
if len(folder_entries) > 0:
folder['Entry'] = folder_entries
# add to output folder
folders.append(folder)
# Root group
root_group = get_folder(dict(name='Root'))
root_group['Group'] = folders
# add items to root folder
if len(root_entries) > 0:
root_group['Entry'] = root_entries
# Root element
root = dict(Group=root_group)
# Meta element
meta = dict(Generator='bitwarden export',
MasterKeyChangeForce=-1, MasterKeyChangeRec=-1)
# add binary files from attachments
if (get_entry.binaries):
meta.update(dict(Binaries=dict(Binary=get_entry.binaries)))
# xml document contents
xml = dict(KeePassFile=dict(Meta=meta, Root=root))
found.sort()
print(found)
if xml_output:
# export unencrypted XML
with write_open(output_file) as out:
out.write(xmltodict.unparse(xml, pretty=True))
out.close()
raise SystemExit
if not is_tool('keepassxc-cli'):
eprint("keepassxc-cli not found")
raise Exception
# write XML and export to keepass
with tempfile.NamedTemporaryFile() as xml_out:
xml_out.write(bytearray(xmltodict.unparse(xml, pretty=True), 'utf-8'))
xml_out.flush()
with tempfile.TemporaryDirectory() as tmpdir:
output_tempfile = tmpdir + '/tmp'
cmd = 'keepassxc-cli import '+xml_out.name+' '+output_tempfile
res = subprocess.run(cmd, input=bytearray(
kee_password, 'utf-8'), shell=True, capture_output=True)
if res.returncode != 0:
eprint("Error exporting KeePass file")
raise SystemExit
del res
safe_move(output_tempfile, output_file)
# cleanup and exit
del kee_password
bw_logout()
sys.exit(0)
if __name__ == "__main__":
import plac
try:
plac.call(main)
except SystemExit:
bw_logout()
sys.exit(0)
except:
import traceback
print("Unexpected error:", sys.exc_info())
print(traceback.format_exc())
bw_logout()
sys.exit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment