Created
December 2, 2025 13:05
-
-
Save cs224/8082b7d0c854310c147d0f90d0d25001 to your computer and use it in GitHub Desktop.
otpauth migration decoder: Convert Google Authenticator data to plain otpauth links
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
| #!/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