|
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) |
I have updated the script:
Also I made it a bit more readable. It should be easier for you to understand what's happening and modify it for your needs.
If you face some issues try the previous version of the script: https://gist.github.com/flaksp/6fe1042e12b6b6908c5193c0d6c124a7/054ba12d96a063a1ee901de2c503eec94567018e