Created
March 21, 2026 07:30
-
-
Save motsmanish/dfb3f1981cb6fe54bb76fed64f879077 to your computer and use it in GitHub Desktop.
Google Authenticator Export Decoder - Zero dependencies. Pure Python stdlib only.
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 | |
| """ | |
| decode_otp.py — Google Authenticator Export Decoder | |
| ===================================================== | |
| Zero dependencies. Pure Python stdlib only. | |
| Usage: | |
| 1. Save your otpauth-migration:// URL to a file: | |
| echo "otpauth-migration://offline?data=..." > /tmp/otp_url.txt | |
| 2. Run: | |
| python3 decode_otp.py | |
| Output: | |
| Writes decoded secrets to /tmp/otp_secrets.txt | |
| ⚠️ DELETE /tmp/otp_secrets.txt immediately after copying secrets to your password manager. | |
| Cleanup: | |
| This script deletes itself and the input file automatically after running. | |
| You must manually delete the output file after you are done: | |
| rm /tmp/otp_secrets.txt | |
| Author: https://gist.github.com/motsmanish | |
| """ | |
| import base64 | |
| import urllib.parse | |
| import os | |
| import sys | |
| INPUT_FILE = "/tmp/otp_url.txt" | |
| OUTPUT_FILE = "/tmp/otp_secrets.txt" | |
| THIS_SCRIPT = os.path.abspath(__file__) | |
| WARNING = """ | |
| ==================================================== | |
| ⚠️ SECURITY WARNING | |
| ==================================================== | |
| This file contains your 2FA secret keys. | |
| Anyone with these secrets can generate your OTP codes. | |
| DELETE THIS FILE immediately after copying | |
| secrets to your password manager. | |
| rm {} | |
| ==================================================== | |
| """.format(OUTPUT_FILE) | |
| def parse_varint(data, pos): | |
| result = 0 | |
| shift = 0 | |
| while pos < len(data): | |
| b = data[pos] | |
| pos += 1 | |
| result |= (b & 0x7F) << shift | |
| if not (b & 0x80): | |
| break | |
| shift += 7 | |
| return result, pos | |
| def parse_chunk(chunk): | |
| fields = {} | |
| i = 0 | |
| while i < len(chunk): | |
| if i >= len(chunk): | |
| break | |
| tag = chunk[i]; i += 1 | |
| field_num = tag >> 3 | |
| wire_type = tag & 0x07 | |
| if wire_type == 2: # length-delimited | |
| length = chunk[i]; i += 1 | |
| val = chunk[i:i + length]; i += length | |
| fields[field_num] = val | |
| elif wire_type == 0: # varint | |
| val, i = parse_varint(chunk, i) | |
| fields[field_num] = val | |
| else: | |
| break | |
| return fields | |
| def decode(url): | |
| data = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)['data'][0] | |
| raw = base64.b64decode(data) | |
| entries = [] | |
| i = 0 | |
| count = 1 | |
| while i < len(raw): | |
| tag = raw[i]; i += 1 | |
| field_num = tag >> 3 | |
| wire_type = tag & 0x07 | |
| if wire_type == 2: | |
| length = raw[i]; i += 1 | |
| chunk = raw[i:i + length]; i += length | |
| if field_num == 1: | |
| fields = parse_chunk(chunk) | |
| secret = base64.b32encode(fields.get(1, b'')).decode() if isinstance(fields.get(1), bytes) else '' | |
| name = fields.get(2, b'').decode('utf-8', errors='ignore') if isinstance(fields.get(2), bytes) else '' | |
| issuer = fields.get(3, b'').decode('utf-8', errors='ignore') if isinstance(fields.get(3), bytes) else '' | |
| entries.append({ | |
| 'index': count, | |
| 'issuer': issuer or '(none)', | |
| 'name': name or '(none)', | |
| 'secret': secret or '(none)', | |
| }) | |
| count += 1 | |
| else: | |
| i += 1 | |
| return entries | |
| def main(): | |
| # Check input file | |
| if not os.path.exists(INPUT_FILE): | |
| print("ERROR: Input file not found: {}".format(INPUT_FILE)) | |
| print("Create it with:") | |
| print(' echo "otpauth-migration://offline?data=..." > {}'.format(INPUT_FILE)) | |
| sys.exit(1) | |
| with open(INPUT_FILE, "r") as f: | |
| url = f.read().strip() | |
| if not url.startswith("otpauth-migration://"): | |
| print("ERROR: File does not contain a valid otpauth-migration:// URL.") | |
| sys.exit(1) | |
| entries = decode(url) | |
| if not entries: | |
| print("ERROR: No OTP entries found in the URL.") | |
| sys.exit(1) | |
| # Write output | |
| lines = [] | |
| lines.append(WARNING) | |
| lines.append("Total entries found: {}\n".format(len(entries))) | |
| lines.append("=" * 52 + "\n") | |
| for e in entries: | |
| lines.append("Entry #{}".format(e['index'])) | |
| lines.append(" Issuer : {}".format(e['issuer'])) | |
| lines.append(" Name : {}".format(e['name'])) | |
| lines.append(" Secret : {}".format(e['secret'])) | |
| lines.append("") | |
| lines.append("=" * 52) | |
| lines.append(WARNING) | |
| output = "\n".join(lines) | |
| with open(OUTPUT_FILE, "w") as f: | |
| f.write(output) | |
| print("✅ Done. Secrets written to: {}".format(OUTPUT_FILE)) | |
| print("⚠️ DELETE the output file after copying to your password manager:") | |
| print(" rm {}".format(OUTPUT_FILE)) | |
| # Cleanup input file and this script | |
| try: | |
| os.remove(INPUT_FILE) | |
| print("🧹 Deleted input file: {}".format(INPUT_FILE)) | |
| except Exception: | |
| pass | |
| try: | |
| os.remove(THIS_SCRIPT) | |
| print("🧹 Deleted script: {}".format(THIS_SCRIPT)) | |
| except Exception: | |
| pass | |
| print("\n⚠️ Clear your terminal history:") | |
| print(" history -c && history -w") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment