Skip to content

Instantly share code, notes, and snippets.

@cs224
Created December 2, 2025 13:05
Show Gist options
  • Select an option

  • Save cs224/8082b7d0c854310c147d0f90d0d25001 to your computer and use it in GitHub Desktop.

Select an option

Save cs224/8082b7d0c854310c147d0f90d0d25001 to your computer and use it in GitHub Desktop.
otpauth migration decoder: Convert Google Authenticator data to plain otpauth links
#!/usr/bin/env python3
"""
Minimal otpauth-migration decoder using only Python standard library
Decodes Google Authenticator export data to standard otpauth URLs
"""
# https://github.com/digitalduke/otpauth-migration-decoder/issues/6
# https://www.rajashekar.org/migrate-otp/
# https://github.com/dim13/otpauth
import base64
import sys
from urllib.parse import quote, unquote, urlparse, parse_qs
def read_varint(data, offset):
"""Read a varint (variable-length integer) from protobuf data"""
result = 0
shift = 0
while offset < len(data):
byte = data[offset]
offset += 1
result |= (byte & 0x7F) << shift
if (byte & 0x80) == 0:
break
shift += 7
return result, offset
def read_length_delimited(data, offset):
"""Read a length-delimited field from protobuf data"""
length, offset = read_varint(data, offset)
return data[offset : offset + length], offset + length
def parse_otp_parameters(data):
"""Parse OtpParameters protobuf message"""
offset = 0
otp = {
"secret": b"",
"name": "",
"issuer": "",
"algorithm": 1, # SHA1
"digits": 1, # 6 digits
"type": 2, # TOTP
}
while offset < len(data):
if offset >= len(data):
break
tag, offset = read_varint(data, offset)
field_number = tag >> 3
wire_type = tag & 0x7
if field_number == 1 and wire_type == 2: # secret
otp["secret"], offset = read_length_delimited(data, offset)
elif field_number == 2 and wire_type == 2: # name
name_bytes, offset = read_length_delimited(data, offset)
otp["name"] = name_bytes.decode("utf-8", errors="replace")
elif field_number == 3 and wire_type == 2: # issuer
issuer_bytes, offset = read_length_delimited(data, offset)
otp["issuer"] = issuer_bytes.decode("utf-8", errors="replace")
elif field_number == 4 and wire_type == 0: # algorithm
otp["algorithm"], offset = read_varint(data, offset)
elif field_number == 5 and wire_type == 0: # digits
otp["digits"], offset = read_varint(data, offset)
elif field_number == 6 and wire_type == 0: # type
otp["type"], offset = read_varint(data, offset)
elif field_number == 7 and wire_type == 0: # counter (skip)
_, offset = read_varint(data, offset)
else:
# Skip unknown fields
if wire_type == 0: # varint
_, offset = read_varint(data, offset)
elif wire_type == 2: # length-delimited
_, offset = read_length_delimited(data, offset)
else:
# Unknown wire type, stop parsing
break
return otp
def parse_payload(data):
"""Parse main Payload protobuf message"""
offset = 0
otp_parameters = []
while offset < len(data):
if offset >= len(data):
break
tag, offset = read_varint(data, offset)
field_number = tag >> 3
wire_type = tag & 0x7
if field_number == 1 and wire_type == 2: # otp_parameters
otp_data, offset = read_length_delimited(data, offset)
otp_params = parse_otp_parameters(otp_data)
otp_parameters.append(otp_params)
else:
# Skip other fields (version, batch_size, etc.)
if wire_type == 0:
_, offset = read_varint(data, offset)
elif wire_type == 2:
_, offset = read_length_delimited(data, offset)
else:
break
return otp_parameters
def build_otpauth_url(otp):
"""Build otpauth URL from OTP parameters"""
# Enum mappings
algorithms = {1: "SHA1", 2: "SHA256", 3: "SHA512", 4: "MD5"}
digits_map = {1: "6", 2: "8"}
types = {1: "hotp", 2: "totp"}
otp_type = types.get(otp["type"], "totp")
algorithm = algorithms.get(otp["algorithm"], "SHA1")
digits = digits_map.get(otp["digits"], "6")
# Convert secret to base32 (no padding)
secret = base64.b32encode(otp["secret"]).decode("ascii").rstrip("=")
name = quote(otp["name"])
# Build URL parameters
params = [f"secret={secret}"]
if otp["issuer"]:
params.append(f'issuer={quote(otp["issuer"])}')
if algorithm != "SHA1":
params.append(f"algorithm={algorithm}")
if digits != "6":
params.append(f"digits={digits}")
return f"otpauth://{otp_type}/{name}?" + "&".join(params)
def decode_migration_data(data_b64):
"""Decode base64 otpauth-migration data to otpauth URLs"""
# Handle URL encoding
data_b64 = unquote(data_b64)
# Decode base64
try:
data = base64.b64decode(data_b64)
except Exception as e:
raise ValueError(f"Invalid base64 data: {e}")
# Parse protobuf and generate URLs
otp_parameters = parse_payload(data)
return [build_otpauth_url(otp) for otp in otp_parameters]
def parse_input(input_str):
"""Extract data from full URL or return input if it's just base64 data"""
if input_str.startswith("otpauth-migration://"):
parsed = urlparse(input_str)
if parsed.scheme != "otpauth-migration" or parsed.hostname != "offline":
raise ValueError("Invalid otpauth-migration URL format")
query_params = parse_qs(parsed.query)
if "data" not in query_params:
raise ValueError("Missing 'data' parameter in URL")
return query_params["data"][0]
return input_str
def main():
if len(sys.argv) != 2:
print(
"Usage: python minimal_decoder.py 'otpauth-migration://...' or 'base64-data'"
)
print(
"Example: python minimal_decoder.py 'otpauth-migration://offline?data=...'"
)
sys.exit(1)
try:
data_b64 = parse_input(sys.argv[1])
urls = decode_migration_data(data_b64)
if not urls:
print("No OTP parameters found in the data", file=sys.stderr)
sys.exit(1)
for url in urls:
print(url)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment