# Local Totp Viewer

> A zero-dependency local TOTP (2FA) code viewer for your own PC. Use when a user wants their own 2FA/TOTP codes displayed on their own desktop, or wants to move their secrets out of Google Authenticator into a local file that never leaves their machine.

- Skill: `restart-notslander/local-totp-viewer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add restart-notslander/local-totp-viewer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/restart-notslander/local-totp-viewer/raw
- Safety review: pending
- Works with: any agent that reads SKILL.md (Claude Code, Claude.ai, Cursor, Codex, Windsurf, 60+ more)
- Category: AI & ML
- License: MIT
- Author: restart.notslander (https://skillmd.com/u/restart-notslander)
- Updated: 2026-10-14
- Page: https://skillmd.com/skills/restart-notslander/local-totp-viewer

---


# 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):

```python
#!/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:

1. In the app on your phone: menu → Transfer accounts → Export accounts → select accounts.
2. Screenshot the QR code and move the image to your own PC.
3. Decode it locally on your PC with a QR library (e.g. `pyzbar`); OpenCV's QR detector often fails on app screenshots.
4. 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"=")`.
5. 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 `-l` if you prefer a static list; with no arguments the viewer refreshes the screen every second.
