Skip to content

Instantly share code, notes, and snippets.

@SensehacK
Last active March 6, 2025 00:08
Show Gist options
  • Save SensehacK/48c36efd582d6bab3d0bebcb46968f5b to your computer and use it in GitHub Desktop.
Save SensehacK/48c36efd582d6bab3d0bebcb46968f5b to your computer and use it in GitHub Desktop.
Authy Migration iOS Guide to 2Fas

Authy Migration on iOS

Requirements

  • mac / PC
  • iOS device
  • follow this guide Authy iOS MiTM
  • After getting the extracted data part you can follow this guide.

Authy

I hate Twilio and authy to the core.

I used this service back in 2016 or something when I had unfortunate issue with Google authenticator where all my TOTPs or OTPs or tokens were lost due to a phone being reset or I did some boot loader change / custom OS installs on my android phones.

At that time I decided that I would never trust all my data to one company especially Google. As much as I hate apple wall garden, I hate google sun-setting or killing the products I love eventually.

Well enough rant, Authy was perfect, it had cross platform support, online sync / backup of my tokens with extra password (encryption layer), full fledge desktop application on mac and windows as well.

when you have so many different devices like Android (daily driver till 2023), iPhone for "it works & facetime with family", mac (dev, good battery, trackpad & display, 2021 redesign + no more x86 intel *chefs kiss), windows PC (gaming, piracy, backwards compatibility, emulation and I grew up with Windows 95, 98, nostalgia)

Authy ticked all the boxes for me personally and kept their users who are diversified in their tech choosing to fulfil their needs.

Long story short, they started the initiative to decomission the desktop support in Aug 2024, I didn't pay attention since I was of an opinion that something good will appear or I would still have ability to run iOS multiplatform app on macOS since the transition to ARM chips. It was officially supported and I loved having my TOTPs on my mac for better productivity. Once they removed official desktop app support, after few months they removed iOS app to be ran on macOS from the app store and also poison pilled the app to force update. I'm still furious, I hate depending on my phone for everything and what if I lose my phone, I still need a backup Desktop PC or a mac just in case for the worst case scenarios.

also this company doesn't give us the option to export our TOTPs to bitwarden or any other platform.

hence my journey began to say FU to Authy and its BS wall garden practices. But it took more time to understand security, reverse engineer, AI, python scripts and inspect various different import / export formats all these apps employ. I really hope we would have a streamline format accepted by everyone like how "Matter" in Home IOT devices have. Either way lot of the tech companies are trying to push password less with Microsoft authenticator, Apple passkeys, iCloud 2FA OTPs, Google "Yes its me prompt" from Google Youtube, photos apps on iOS and native toast notification on Android OS. But I don't want to have my data being dependent again on big tech, so I decided to move to open source.

I have also transitioned from 1Password 4,5,6,7 to Bitwarden free version. Sadly they require premium subscription for having TOTPs functionality. Also I hate 1Password hybrid app move on version 8 - electron wrapper. And I don't want to host my data r/selfhosted with Vaultwarden which also requires a domain to point your data too. You can avoid that as well but defeats the purpose.

So I just went with apple walled garden approach and have icloud sync with my 2Fas app Open source implementation with their server, UI code (iOS & android) as well

https://github.com/twofas

It supports my use case - minus the cross platform part like windows desktop app. macOS desktop app could be supported, there's already an issue for supporting it.

Maybe I can contribute to open source :)

I stumbled upon various threads on reddit, twitter, github gists, github issues. At the end i decided to utilize my lab week to finally tackle this problem I have and also try some AI. I just tried prompt engineering or whatever its called. I gave a simple task outlined here

At the end I was able to generate, extract, decrypt, convert, convert again, different formats etc and was able to export my 44 Authy TOTPs to open source iOS app called 2Fas

It also supports dark mode and the export perfectly worked with basic icons. Nice I also considered Raivo OTP github

Tl,dr; - :duck u Twillio and your gatekeeping scenarios for making your long time users be stuck with you. Switched to Open Source. This is the Way!

import json
import base64
import binascii # For base16 decoding
from getpass import getpass # For hidden password input
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
def decrypt_token(kdf_rounds, encrypted_seed_b64, salt, iv, passphrase):
try:
# Decode the base64-encoded encrypted seed
encrypted_seed = base64.b64decode(encrypted_seed_b64)
# Derive the encryption key using PBKDF2 with SHA-1
kdf = PBKDF2HMAC(
algorithm=hashes.SHA1(),
length=32, # AES-256 requires a 32-byte key
salt=salt.encode(),
iterations=kdf_rounds,
backend=default_backend()
)
key = kdf.derive(passphrase.encode())
# AES with CBC mode
# Some versions of Authy used an IV, while others used a null IV. We account for both cases here.
if not iv:
iv = bytes([0] * 16)
else:
iv = binascii.unhexlify(iv)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
# Decrypt the ciphertext
decrypted_data = decryptor.update(encrypted_seed) + decryptor.finalize()
# Remove PKCS7 padding
padding_len = decrypted_data[-1]
padding_start = len(decrypted_data) - padding_len
# Validate padding
if padding_len > 16 or padding_start < 0:
raise ValueError("Invalid padding length")
if not all(pad == padding_len for pad in decrypted_data[padding_start:]):
raise ValueError("Invalid padding bytes")
return decrypted_data[:padding_start].decode('utf-8')
except Exception as e:
return f"Decryption failed: {str(e)}"
def process_authenticator_data(input_file, output_file, backup_password):
with open(input_file, "r") as json_file:
data = json.load(json_file)
decrypted_tokens = []
for token in data['authenticator_tokens']:
decrypted_seed = decrypt_token(
kdf_rounds=token['key_derivation_iterations'],
encrypted_seed_b64=token['encrypted_seed'],
salt=token['salt'],
iv=token['unique_iv'],
passphrase=backup_password
)
decrypted_token = {
"account_type": token["account_type"],
"name": token["name"],
"issuer": token["issuer"],
"decrypted_seed": decrypted_seed, # Store as UTF-8 string
"digits": token["digits"],
"logo": token["logo"],
"unique_id": token["unique_id"]
}
decrypted_tokens.append(decrypted_token)
output_data = {
"message": "success",
"decrypted_authenticator_tokens": decrypted_tokens,
"success": True
}
with open(output_file, "w") as output_json_file:
json.dump(output_data, output_json_file, indent=4)
print(f"Decryption completed. Decrypted data saved to '{output_file}'.")
# User configuration
input_file = "authenticator_tokens.json" # Replace with your input file
output_file = "decrypted_tokens.json" # Replace with your desired output file
# Prompt for the backup password at runtime (hidden input)
backup_password = getpass("Enter the backup password: ").strip()
# Process the file
process_authenticator_data(input_file, output_file, backup_password)
import json
import urllib.parse
# Load source content from file
with open("decrypted_tokens.json", "r") as f:
authy_data = json.load(f)
# Convert to Bitwarden format
bitwarden_data = {"items": []}
for account in authy_data["decrypted_authenticator_tokens"]:
# Ensure all fields are strings or provide defaults
name = str(account.get("name", ""))
issuer = account.get("issuer") # May be None
secret = str(account.get("decrypted_seed", ""))
digits = str(account.get("digits", "6"))
# Construct TOTP URI
totp_uri = f"otpauth://totp/{urllib.parse.quote(issuer or '')}:{urllib.parse.quote(name)}?"
totp_uri += f"secret={urllib.parse.quote(secret)}&digits={digits}"
if issuer: # Only add issuer parameter if it exists
totp_uri += f"&issuer={urllib.parse.quote(issuer)}"
# Add to Bitwarden items
bitwarden_data["items"].append({
"name": name,
"type": 1, # Type 1 is for login items
"login": {
"username": name,
"totp": totp_uri
}
})
# Save to JSON file
with open("vaultwarden_import.json", "w") as f:
json.dump(bitwarden_data, f, indent=4)
print("Vaultwarden import file created: vaultwarden_import.json")
{
"items": [
{
"name": "Kay",
"type": 1,
"login": {
"username": "KautilyaSave",
"totp": "otpauth://totp/:Kay?secret=safa2&digits=6"
}
},
{
"name": "Kautilya",
"type": 1,
"login": {
"username": "sensehack",
"totp": "otpauth://totp/:Kautilya?secret=safa2&digits=6"
}
}
]
}
import json
import uuid
import urllib.parse
def extract_totp_secret(totp_uri):
"""
Extract TOTP secret from otpauth URI
"""
try:
parsed_url = urllib.parse.urlparse(totp_uri)
query_params = urllib.parse.parse_qs(parsed_url.query)
return query_params.get('secret', [None])[0]
except Exception:
return None
def convert_vaultwarden_to_raivo(input_file, output_file):
"""
Convert Vaultwarden JSON export to Raivo OTP format.
:param input_file: Path to the input Vaultwarden JSON file
:param output_file: Path to save the converted Raivo OTP JSON file
"""
# Read Vaultwarden JSON file
with open(input_file, 'r', encoding='utf-8') as f:
vaultwarden_data = json.load(f)
# Prepare Raivo OTP format
raivo_otps = []
# Iterate through items
for item in vaultwarden_data.get('items', []):
# Check if the item has a login with TOTP
if item.get('type') == 1 and 'login' in item:
login = item['login']
totp_uri = login.get('totp', '')
# Extract secret from URI
otp_secret = extract_totp_secret(totp_uri)
# Skip if no OTP secret
if not otp_secret:
continue
# Prepare Raivo OTP entry
raivo_entry = {
"id": str(uuid.uuid4()), # Generate a unique ID
"issuer": item.get('name', ''),
"label": login.get('username', ''),
"secret": otp_secret,
"type": "TOTP",
"algorithm": "SHA1", # Default algorithm for most TOTP
"digits": 6, # Most OTP use 6 digits
"period": 30 # Standard 30-second period
}
raivo_otps.append(raivo_entry)
# Write Raivo OTP JSON file
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(raivo_otps, f, indent=2, ensure_ascii=False)
print(f"Converted {len(raivo_otps)} OTP entries.")
def main():
# Example usage
input_file = 'vaultwarden_import.json'
output_file = 'raivo_otps_third_try.json'
convert_vaultwarden_to_raivo(input_file, output_file)
if __name__ == '__main__':
main()

Kay AI Interactions

Info

Whole convo https://claude.ai/share/a8b82141-f215-4dd9-9da7-0a0adc756bf7

Context:I used Claude bcoz that's the snippet i was shared by some random redditor. quickly had to create an account with google and it required my phone number as well. Why to uniquely identify people to not abuse their API ? Idk

Questions

Question 1 i have a vaultwarden json format file. i want to convert it to Raivo OTP format. can you please write a python script to do this

Question 2 It just gave me "Converted 0 OTP entries."

Question 3 snippet of your Vaultwarden JSON file attached

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