Skip to content

Instantly share code, notes, and snippets.

@flaksp
Last active October 10, 2024 07:21
Show Gist options
  • Save flaksp/6fe1042e12b6b6908c5193c0d6c124a7 to your computer and use it in GitHub Desktop.
Save flaksp/6fe1042e12b6b6908c5193c0d6c124a7 to your computer and use it in GitHub Desktop.
Convert BitWarden JSON export file to Apple iCloud Keychain CSV import file saving TOTP and notes

BitWarden to Apple iCloud Keychain passwords converter

This Python scripts allows you to move your passwords from BitWarden to Apple iCloud.

You need to know:

  • It ignores secure notes, credit cards and other types that are not passwords.
  • It ignores BitWarden entries without usernames, passwords and URLs.
  • It also ignores URLs that do not start with http:// or https://.
  • It normalizes all TOTP tokens, e.g. wskg vtqa h5kl bhb4 v4v2 ybyo woc6 qme2 will be converted to otpauth://totp/example.com:dude@foo.bar?secret=WSKGVTQAH5KLBHB4V4V2YBYOWOC6QME2&issuer=example.com&algorithm=SHA1&digits=6&period=30.
  • It preserves names you set for your passwords.
  • It preserves notes attached to accounts.
  • It preserves all custom fields attached to accounts. They will be prepended to notes.
  • BitWarden export does not contain any information about attachments. You should care about them manually.
  • All entries with multiple URLs (domains) attached will be saved as separate iCloud entries. It's not possible to specify multiple domains for single account yet when importing passwords. See discussion below.

All ignored (filtered) BitWarden entries will be logged to the console during convertion process.

Getting started

Export your BitWarden passwords

You should export your BitWarden vault in JSON format (not encrypted). See "Export Vault Data". Exported file will be named like bitwarden_export_20220426113920.json, you should rename it to bitwarden.json.

Run script

Download Python file attached to the Gist. Place this file in the same directory with bitwarden.json file and run it:

python3 convert.py

It will generate icloud.csv file.

Import passwords to iCloud

To import passwords, you should use generated icloud.csv file. See "Import bookmarks, history, and passwords in Safari on Mac".

Bonus

iOS 18, iPadOS 18, macOS 15 and visionOS 2

Apple finally released Passwords app!

Tips:

  • On macOS you can add it to the menu bar via app settings.
  • On iOS and iPadOS you can use "Open App" control in the Control Center and on the Lock Screen.

Older versions of OS

Use a shortcut to quickly open Passwords screen in the Settings app on your Apple devices: https://www.reddit.com/r/shortcuts/comments/w1sa8w/updated_for_macos_13_ventura_and_ios_16_go/

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)
@nicx
Copy link

nicx commented Oct 10, 2023

@flaksp I get the same error as @skripter888. Any idea how to fix it?

@florian583
Copy link

florian583 commented Oct 24, 2023

@nicx For anyone having the error "unsupported format string passed to NoneType,format" you can comment those 2 lines in the script, the issue is with the parsing of the "fields" from Bitwarden that are anyways added as Note.

        # if 'fields' in credentials_entry:
        #     notes = list(map(lambda field: "{:s}: {:s}".format(field['name'], field['value']), credentials_entry['fields']))

        # if credentials_entry['notes'] is not None:
        #     notes.append(credentials_entry['notes'].strip())

@Aleksandir
Copy link

Thank you! this is invaluable

@Tracnac
Copy link

Tracnac commented Mar 30, 2024

Thanks, but beware that this program silently discard uri that does not start with 'http' or 'https'
(line 70/71)

@flaksp
Copy link
Author

flaksp commented Mar 30, 2024

I have updated the script:

  • An exported file will contain names of your BitWarden records.
  • Added more validations to prevent errors during script execution.
  • And also it is more verbose on logging now. It will be easier to understand why a record was skipped. And no more "silent" skips (@Tracnac, thanks for noticing this).

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

@Tracnac
Copy link

Tracnac commented Apr 1, 2024

  • And also it is more verbose on logging now. It will be easier to understand why a record was skipped. And no more "silent" skips (@Tracnac, thanks for noticing this).

Thanks for your update.

@Mationsills39
Copy link

Thankssss

@vulc41n
Copy link

vulc41n commented Jun 1, 2024

Thank you :)

@elwintero
Copy link

Thank you! Works fine and did what I expected!

@anuraagm0
Copy link

Is it able to handle multiple URLs? I have had this issue where if I use same username and password for primevideo.com and amazon.com and amazon.ca. Will it create 3 accounts or merge them into 1?

@flaksp
Copy link
Author

flaksp commented Jun 21, 2024

@anuraagm0, it will create 3 accounts in that case. That's the limitation of iCloud Keychain.

@anuraagm0
Copy link

Unfortunately that causes too much noise. It’s like 300 passwords suddenly become 500 and keep complaining that I am reusing them while they are part of the same login experience.

@lotfielhafi
Copy link

@flaksp, thank you for this great script. I noticed that some of my entries with Japanese characters in fields were not converted correctly, probably due to a Unicode issue. Do you have any idea how to fix it?

@flaksp
Copy link
Author

flaksp commented Jun 30, 2024

@lotfielhafi, I've tested the script with Japanese characters (in the fields especially) and didn't found any oblivious issues. What kind of problem do you experience? Some more information that fill help with reproduction of the issue would be nice :)

@lotfielhafi
Copy link

@flaksp My bad, after reading your comment, I confirmed that the issue was on my side. I inspected the output file with Excel, which did not render it in Unicode. I am very sorry for the trouble. Thank you again for your script. I could import 600+ entries into Keychain.

@jvacek
Copy link

jvacek commented Sep 17, 2024

Looks like 2 websites per credential is now possible, curious what the format for this is?
image

When exporting this one specifically, there is no url in the column. strange...
image

@anuraagm0
Copy link

Looks like 2 websites per credential is now possible, curious what the format for this is? image

When exporting this one specifically, there is no url in the column. strange... image

I noticed the same issue. It seems the URL is being pulled from the title field. I understand it’s unusual how the data is stored, but in your screenshot, website1.com will be exported if it’s entered in the title during creation. However, you still won’t be able to export website2.net. Since Apple doesn’t support exporting multiple URLs, it’s unclear whether we can programmatically add them.

@jvacek
Copy link

jvacek commented Sep 17, 2024

I filed a bug report via the feedback app 🤷 🤞

@Practicalbutterfly5
Copy link

Practicalbutterfly5 commented Sep 25, 2024

How is this script different from the inbuilt import option that is available in password app File>Import Passwords and using it with Bitwarden app exported .csv? Any advantage it offers?

@10bn
Copy link

10bn commented Sep 25, 2024

Thanks for your work, I made some changes for "It ignores BitWarden entries without usernames, passwords and URLs." which i wanted to be replace with placeholders.

https://gist.github.com/10bn/a18248bb8e96eeda98f1071ad3c38059

@kulapoo
Copy link

kulapoo commented Sep 29, 2024

is this safe to execute?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment