|
import csv |
|
import json |
|
from urllib.parse import urlparse |
|
from urllib.parse import urlunparse |
|
from urllib.parse import urlencode |
|
from urllib.parse import parse_qs |
|
|
|
|
|
def format_totp_url(totp, hostname, username): |
|
if totp.startswith('otpauth://'): |
|
parsed_totp = urlparse(totp) |
|
|
|
totp = parse_qs(parsed_totp.query)['secret'][0] |
|
|
|
totp = totp.upper().replace(' ', '') |
|
|
|
return urlunparse(( |
|
'otpauth', |
|
'totp', |
|
(hostname + ':' + username), |
|
'', |
|
urlencode({ |
|
'secret': totp, |
|
'issuer': hostname, |
|
'algorithm': 'SHA1', |
|
'digits': '6', |
|
'period': '30', |
|
}), |
|
'' |
|
)) |
|
|
|
|
|
# Creates a value for "Notes" field for iCloud |
|
def format_notes(bitwarden_record): |
|
notes_lines = [] |
|
|
|
name = bitwarden_record['name'] |
|
|
|
if 'fields' in bitwarden_record and isinstance(bitwarden_record['fields'], list): |
|
field_dicts = bitwarden_record['fields'] |
|
|
|
for field_dict in field_dicts: |
|
if not isinstance(field_dict, dict): |
|
print('Skipping a custom field in "{:s}" because it is not a dictionary'.format(name, json.dumps(field_dict))) |
|
|
|
continue |
|
|
|
if 'name' not in field_dict or not isinstance(field_dict['name'], str): |
|
print('Skipping a custom field in "{:s}" because it does not have `name` field or the field is not a string'.format(name)) |
|
|
|
continue |
|
|
|
custom_field_name = field_dict['name'] |
|
|
|
if 'value' not in field_dict or not isinstance(field_dict['value'], str): |
|
print('Skipping a custom field in "{:s}" because it does not have `value` field or the field is not a string'.format(name)) |
|
|
|
continue |
|
|
|
custom_field_value = field_dict['value'] |
|
|
|
notes_lines.append("{:s}: {:s}".format(custom_field_name.strip(), custom_field_value.strip())) |
|
|
|
if 'notes' in bitwarden_record and isinstance(bitwarden_record['notes'], str): |
|
notes_lines.append(bitwarden_record['notes'].strip()) |
|
|
|
return "\n\n".join(notes_lines) |
|
|
|
|
|
# It converts a single BitWarden record to 1 or more iCloud records (1 record per URL), and returns 0 records if it's not convertable |
|
def convert_bitwarden_record_to_icloud_records(bitwarden_record): |
|
if 'name' not in bitwarden_record or not isinstance(bitwarden_record['name'], str): |
|
print('Skipping an item because it does not have `name` field or the field is not a string: {:s}'.format(json.dumps(bitwarden_record))) |
|
|
|
return [] |
|
|
|
name = bitwarden_record['name'] |
|
|
|
if 'type' not in bitwarden_record or not isinstance(bitwarden_record['type'], int): |
|
print('Skipping "{:s}" because it does not have `type` field or the field is not an integer'.format(name)) |
|
|
|
return [] |
|
|
|
bitwarden_record_type = bitwarden_record['type'] |
|
|
|
# Checking BitWarden item has a password type |
|
if bitwarden_record_type != 1: |
|
print('Skipping "{:s}" because it is not a password (it has type `{:d}`)'.format(name, bitwarden_record_type)) |
|
|
|
return [] |
|
|
|
if 'login' not in bitwarden_record or not isinstance(bitwarden_record['login'], dict): |
|
print('Skipping "{:s}" because it does not have `login` field or the field is not a dictionary'.format(name)) |
|
|
|
return [] |
|
|
|
login = bitwarden_record['login'] |
|
|
|
if 'username' not in login or not isinstance(login['username'], str): |
|
print('Skipping "{:s}" because it missing a username'.format(name)) |
|
|
|
return [] |
|
|
|
username = login['username'] |
|
|
|
if 'password' not in login or not isinstance(login['password'], str): |
|
print('Skipping "{:s}" because it missing a password'.format(name)) |
|
|
|
return [] |
|
|
|
password = login['password'] |
|
|
|
if 'uris' not in login or not isinstance(login['uris'], list): |
|
print('Skipping "{:s}" because it does not have website URLs'.format(name)) |
|
|
|
return [] |
|
|
|
notes = format_notes(bitwarden_record) |
|
|
|
uri_dicts = login['uris'] |
|
|
|
icloud_accounts = [] |
|
|
|
for uri_dict in uri_dicts: |
|
if 'uri' not in uri_dict or not isinstance(uri_dict['uri'], str): |
|
print('Could not process URL in "{:s}" because it does not have `uri` field or the field is not a string'.format(name)) |
|
|
|
continue |
|
|
|
uri = uri_dict['uri'] |
|
|
|
try: |
|
parsed_uri = urlparse(uri) |
|
|
|
except AttributeError: |
|
print('Could not process URL "{:s}" in "{:s}" because it is not valid'.format(uri, name)) |
|
|
|
continue |
|
|
|
if parsed_uri.scheme != 'http' and parsed_uri.scheme != 'https': |
|
print('Could not process URL "{:s}" in "{:s}" because it does not start with `http://` or `https://`'.format(uri, name)) |
|
|
|
continue |
|
|
|
icloud_accounts.append([ |
|
name, |
|
uri, |
|
username, |
|
password, |
|
notes, |
|
'' if 'totp' not in login or not isinstance(login['totp'], str) else format_totp_url( |
|
login['totp'], |
|
parsed_uri.hostname, |
|
username, |
|
) |
|
]) |
|
|
|
return icloud_accounts |
|
|
|
|
|
bitwarden_export_json_file = open('bitwarden.json') |
|
parsed_bitwarden_export = json.load(bitwarden_export_json_file) |
|
|
|
print('Found {:d} accounts in BitWarden'.format(len(parsed_bitwarden_export['items']))) |
|
|
|
icloud_records = [] |
|
|
|
for bitwarden_record in parsed_bitwarden_export['items']: |
|
icloud_records.extend(convert_bitwarden_record_to_icloud_records(bitwarden_record)) |
|
|
|
print('Saving {:d} accounts to iCloud CSV'.format(len(icloud_records))) |
|
|
|
with open('icloud.csv', 'w', encoding='UTF8', newline='') as csv_file: |
|
writer = csv.writer(csv_file) |
|
|
|
writer.writerow(['Title', 'URL', 'Username', 'Password', 'Notes', 'OTPAuth']) |
|
writer.writerows(icloud_records) |
Thanks!