Local TOTP Viewer
A single-file Python script that shows your own TOTP (RFC 6238) codes on your own PC — no phone, no external app, no dependencies. You run it yourself; your secrets never leave your machine.
What this is — and what it is not
- Is: a personal desktop viewer for the machine's owner. It reads the secrets you registered locally and displays the current codes on your screen.
- Is not: a way to hand 2FA secrets or codes to anyone else, including AI agents. The script makes no network calls; secrets and codes are only ever shown on your own display, for you to read and type into the service you are signing in to.
When to use
- You want your own 2FA codes displayed on your own desktop instead of a phone.
- You are moving your secrets from Google Authenticator into a local file on your own PC.
- You want a small CLI tool that you run yourself to check your own codes while working on that machine.
Privacy model
- All secrets live in one local file,
~/.2fa_secrets.json(mode 600), on the machine where you run the script. - The script performs no network requests. Secrets and generated codes never leave your machine.
- Codes are for you, the machine's owner, to read from your own screen and enter into your own login forms. No one else — including any AI agent — needs access to your secrets or your codes.
The script
Save this as ~/2fa_view.py (pure Python stdlib — nothing to install):
#!/usr/bin/env python3
"""Local 2FA code viewer (for your own PC).
Usage:
python3 2fa_view.py # live view of all codes (Ctrl+C to quit)
python3 2fa_view.py <secret> [name] # register a base32 secret
python3 2fa_view.py "otpauth://..." # register from a QR-code URI
python3 2fa_view.py -l # list registered accounts
python3 2fa_view.py -r <name> # remove an account
Registered secrets are stored in ~/.2fa_secrets.json (mode 600) on this
machine only.
"""
import sys, json, time, hmac, hashlib, base64, struct, os, re, subprocess
from pathlib import Path
SECRETS_FILE = Path.home() / '.2fa_secrets.json'
def load_secrets():
if SECRETS_FILE.exists():
try:
return json.loads(SECRETS_FILE.read_text())
except Exception:
return {}
return {}
def save_secrets(d):
SECRETS_FILE.write_text(json.dumps(d, indent=2, ensure_ascii=False))
os.chmod(SECRETS_FILE, 0o600)
def totp(secret: str, digits: int = 6, period: int = 30, algo: str = 'sha1') -> str:
"""RFC 6238 TOTP"""
secret = secret.strip().replace(' ', '').upper()
pad = '=' * ((8 - len(secret) % 8) % 8)
key = base64.b32decode(secret + pad)
counter = int(time.time()) // period
msg = struct.pack('>Q', counter)
h = {'sha1': hashlib.sha1, 'sha256': hashlib.sha256, 'sha512': hashlib.sha512}[algo]
digest = hmac.new(key, msg, h).digest()
offset = digest[-1] & 0x0F
code = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
def parse_uri(uri: str):
"""otpauth://totp/LABEL?secret=X&digits=6&period=30&algorithm=SHA1"""
m = re.match(r'otpauth://totp/([^?]+)\?(.*)', uri)
if not m:
return None
label = m.group(1)
params = dict(p.split('=', 1) for p in m.group(2).split('&') if '=' in p)
return {
'label': label,
'secret': params.get('secret', ''),
'digits': int(params.get('digits', 6)),
'period': int(params.get('period', 30)),
'algorithm': params.get('algorithm', 'SHA1').lower(),
}
def show(name: str, cfg: dict):
code = totp(cfg['secret'], cfg.get('digits', 6), cfg.get('period', 30), cfg.get('algorithm', 'sha1'))
period = cfg.get('period', 30)
remain = period - (int(time.time()) % period)
bar = '#' * int(remain / period * 20) + '-' * (20 - int(remain / period * 20))
return f"{name:20s} {code} [{bar}] {remain:2d}s left"
def main():
secrets = load_secrets()
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg.startswith('otpauth://'):
parsed = parse_uri(arg)
if not parsed:
print('Invalid otpauth URI')
return 1
name = parsed['label']
secrets[name] = {'secret': parsed['secret'], 'digits': parsed['digits'],
'period': parsed['period'], 'algorithm': parsed['algorithm']}
save_secrets(secrets)
print(f"Registered: {name}")
print(show(name, secrets[name]))
return 0
elif arg in ('-l', '--list'):
if not secrets:
print('Nothing registered. Example: python3 2fa_view.py JBSWY3DPEHPK3PXP')
return 0
for n, c in secrets.items():
print(show(n, c))
return 0
elif arg in ('-r', '--remove'):
target = sys.argv[2] if len(sys.argv) > 2 else None
if target and target in secrets:
del secrets[target]
save_secrets(secrets)
print(f"Removed: {target}")
return 0
else:
# register the secret itself, with an optional name
name = sys.argv[2] if len(sys.argv) > 2 else 'default'
secrets[name] = {'secret': arg, 'digits': 6, 'period': 30, 'algorithm': 'sha1'}
save_secrets(secrets)
print(f"Registered: {name}")
print(show(name, secrets[name]))
return 0
if not secrets:
print('Usage:')
print(' python3 2fa_view.py <secret> [name] # register')
print(' python3 2fa_view.py "otpauth://..." # register from QR URI')
print(' python3 2fa_view.py # live view of all codes')
print(' python3 2fa_view.py -l # list')
print(' python3 2fa_view.py -r <name> # remove')
return 0
# Live view (no arguments)
try:
while True:
# fixed command 'clear' only (no user input involved)
subprocess.run(['clear'], check=False)
print('=== 2FA codes (Ctrl+C to quit) ===\n')
for n, c in secrets.items():
print(show(n, c))
time.sleep(1)
except KeyboardInterrupt:
print('\nBye')
return 0
if __name__ == '__main__':
sys.exit(main())
Verify against the RFC 6238 test vector (secret GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ, T=59s, SHA1 → 94287082) before trusting it.
Registering your accounts
- From a base32 secret:
python3 2fa_view.py JBSWY3DPEHPK3PXP github - From an
otpauth://URI (e.g. one you decoded from a QR code):python3 2fa_view.py "otpauth://totp/GitHub:me?secret=..."
Migrating your secrets from Google Authenticator (all local)
Google Authenticator can export all accounts as a single QR code:
- In the app on your phone: menu → Transfer accounts → Export accounts → select accounts.
- Screenshot the QR code and move the image to your own PC.
- Decode it locally on your PC with a QR library (e.g.
pyzbar); OpenCV's QR detector often fails on app screenshots. - The payload is an
otpauth-migration://offline?data=<base64>URI. URL-decode it, then parse the protobuf locally:- field 1 (
otp_parameters): secret = field 1 (bytes), name = field 2, issuer = field 3, algo = field 4 (1=SHA1), digits = field 5 (1=6 digits), type = field 6 (2=TOTP). - Encode each secret as base32:
base64.b32encode(secret).rstrip(b"=").
- field 1 (
- Register each secret with the viewer script.
Keep this export entirely on your own machine — the QR contains every secret, so delete the screenshot once you have registered the accounts.
Pitfalls
- Codes are valid for 30 seconds: read the code from the viewer right before typing it into the login form.
- Keep the secrets file at mode 600 (the script sets this on every save) and avoid pasting secrets into logs, chat, or transcripts.
- Use
-lif you prefer a static list; with no arguments the viewer refreshes the screen every second.