-
-
Save 10bn/a18248bb8e96eeda98f1071ad3c38059 to your computer and use it in GitHub Desktop.
Convert bitwarden to icloud
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import csv | |
import json | |
import re | |
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs | |
# ============================== | |
# Placeholder Definitions | |
# ============================== | |
# Placeholder prefix for usernames when missing | |
PLACEHOLDER_USERNAME_PREFIX = 'placeholder_user_' | |
# Placeholder value for passwords when missing | |
PLACEHOLDER_PASSWORD = 'placeholder_password_' | |
# Placeholder prefix for URLs when missing or invalid | |
PLACEHOLDER_URL_PREFIX = 'https://placeholder.example.com/' | |
# ============================== | |
# Function Definitions | |
# ============================== | |
def format_totp_url(totp, hostname, username): | |
""" | |
Formats a TOTP (Time-based One-Time Password) URL for use in iCloud Keychain. | |
Parameters: | |
totp (str): The TOTP secret or URL. | |
hostname (str): The hostname of the website. | |
username (str): The username for the account. | |
Returns: | |
str: A formatted otpauth URL. | |
""" | |
if totp.startswith('otpauth://'): | |
# Extract the secret from the existing otpauth URL | |
parsed_totp = urlparse(totp) | |
totp = parse_qs(parsed_totp.query)['secret'][0] | |
# Normalize the TOTP secret | |
totp = totp.upper().replace(' ', '') | |
# Build the otpauth URL | |
return urlunparse(( | |
'otpauth', | |
'totp', | |
f'{hostname}:{username}', | |
'', | |
urlencode({ | |
'secret': totp, | |
'issuer': hostname, | |
'algorithm': 'SHA1', | |
'digits': '6', | |
'period': '30', | |
}), | |
'' | |
)) | |
def sanitize_string(s): | |
""" | |
Sanitizes a string to be URL and filename safe. | |
Parameters: | |
s (str): The string to sanitize. | |
Returns: | |
str: The sanitized string. | |
""" | |
# Remove any characters that are not letters, numbers, underscores, or hyphens | |
value = re.sub(r'[^a-zA-Z0-9_\-]', '_', s) | |
print(f'Sanitized "{s}" to "{value}"') | |
return value | |
def format_notes(bitwarden_record): | |
""" | |
Formats the notes and custom fields from a BitWarden record. | |
Parameters: | |
bitwarden_record (dict): The BitWarden record. | |
Returns: | |
str: A string containing the notes and custom fields. | |
""" | |
notes_lines = [] | |
name = bitwarden_record.get('name', 'Unknown') | |
# Get custom fields if they exist | |
fields = bitwarden_record.get('fields', []) | |
if isinstance(fields, list): | |
for field_dict in fields: | |
if isinstance(field_dict, dict): | |
custom_field_name = field_dict.get('name', '') | |
custom_field_value = field_dict.get('value', '') | |
# Ensure both are strings before stripping | |
if isinstance(custom_field_name, str): | |
custom_field_name = custom_field_name.strip() | |
else: | |
custom_field_name = '' | |
if isinstance(custom_field_value, str): | |
custom_field_value = custom_field_value.strip() | |
else: | |
custom_field_value = '' | |
if custom_field_name and custom_field_value: | |
notes_lines.append(f"{custom_field_name}: {custom_field_value}") | |
# Get the standard notes field | |
notes = bitwarden_record.get('notes', '') | |
if isinstance(notes, str) and notes.strip(): | |
notes_lines.append(notes.strip()) | |
# Combine all notes and custom fields | |
return "\n\n".join(notes_lines) | |
def convert_bitwarden_record_to_icloud_records(bitwarden_record): | |
""" | |
Converts a single BitWarden record into one or more iCloud Keychain records. | |
Parameters: | |
bitwarden_record (dict): The BitWarden record. | |
Returns: | |
list: A list of iCloud Keychain records (each is a list of fields). | |
""" | |
name = bitwarden_record.get('name', 'Unknown') | |
sanitized_name = sanitize_string(name) or 'Unknown' | |
bitwarden_record_type = bitwarden_record.get('type', 1) | |
# Proceed only if the type is one of the known types | |
if bitwarden_record_type not in [1, 2, 3, 4]: | |
print(f'Skipping "{name}" because it is of an unknown type (it has type `{bitwarden_record_type}`)') | |
return [] | |
# Get the login details | |
login = bitwarden_record.get('login', {}) | |
if not isinstance(login, dict): | |
login = {} | |
# Get the username, ensure it's a string | |
username = login.get('username', '') | |
if not isinstance(username, str): | |
username = '' | |
username = username.strip() | |
if not username: | |
print(f'Entry "{name}" is missing a username. Using placeholder.') | |
username = f'{PLACEHOLDER_USERNAME_PREFIX}{sanitized_name}' | |
# Get the password, ensure it's a string | |
password = login.get('password', '') | |
if not isinstance(password, str): | |
password = '' | |
password = password.strip() | |
if not password: | |
print(f'Entry "{name}" is missing a password. Using placeholder.') | |
password = PLACEHOLDER_PASSWORD | |
# Get the list of URIs, or use a placeholder if missing | |
uri_dicts = login.get('uris', []) | |
if not isinstance(uri_dicts, list) or not uri_dicts: | |
print(f'Entry "{name}" does not have website URLs. Using placeholder URL.') | |
# Create a unique placeholder URL using the sanitized name | |
uri_dicts = [{'uri': f'{PLACEHOLDER_URL_PREFIX}{sanitized_name}'}] | |
# Format notes and custom fields | |
notes = format_notes(bitwarden_record) | |
icloud_accounts = [] | |
for uri_dict in uri_dicts: | |
# Get the URI, ensure it's a string | |
uri = uri_dict.get('uri', '') | |
if not isinstance(uri, str): | |
uri = '' | |
uri = uri.strip() | |
if not uri: | |
print(f'Could not process URL in "{name}" because it is invalid. Using placeholder URL.') | |
uri = f'{PLACEHOLDER_URL_PREFIX}{sanitized_name}' | |
if not uri.startswith('http://') and not uri.startswith('https://'): | |
print(f'URI "{uri}" in "{name}" does not start with http:// or https://. Prepending https://') | |
uri = 'https://' + uri | |
try: | |
# Parse the URI to extract components | |
parsed_uri = urlparse(uri) | |
except Exception as e: | |
print(f'Could not process URL "{uri}" in "{name}" because it is not valid: {e}') | |
continue | |
# Format TOTP if available and valid | |
totp = '' | |
if 'totp' in login: | |
totp_value = login['totp'] | |
if isinstance(totp_value, str) and totp_value.strip(): | |
totp = format_totp_url(totp_value, parsed_uri.hostname or 'example.com', username) | |
# Append the record to the list | |
icloud_accounts.append([ | |
name, | |
uri, | |
username, | |
password, | |
notes, | |
totp | |
]) | |
return icloud_accounts | |
# ============================== | |
# Main Script Execution | |
# ============================== | |
# Open and parse the BitWarden JSON export file | |
with open('bitwarden.json', 'r', encoding='utf-8') as bitwarden_export_json_file: | |
parsed_bitwarden_export = json.load(bitwarden_export_json_file) | |
print(f'Found {len(parsed_bitwarden_export["items"])} accounts in BitWarden') | |
icloud_records = [] | |
# Convert each BitWarden record to iCloud format | |
for bitwarden_record in parsed_bitwarden_export['items']: | |
icloud_records.extend(convert_bitwarden_record_to_icloud_records(bitwarden_record)) | |
print(f'Saving {len(icloud_records)} accounts to iCloud CSV') | |
# Write the iCloud records to a CSV file | |
with open('icloud.csv', 'w', encoding='UTF8', newline='') as csv_file: | |
writer = csv.writer(csv_file) | |
# Write the header row | |
writer.writerow(['Title', 'URL', 'Username', 'Password', 'Notes', 'OTPAuth']) | |
# Write the data rows | |
writer.writerows(icloud_records) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment