Standalone RFC 6238 TOTP (time-based one-time code) generator. Pure Python standard library — no pyotp / oathtool. Lives at ~/bin/totp.py. Contains NO secret: the base32 secret is supplied at runtime (first positional argument, --secret-file, $TOTP_SECRET, or a hidden prompt), never embedded — safe to keep in a public note. Verified against the RFC 6238 SHA1 test vectors (94287082 / 07081804 / 89005924 at the reference times). Written for AWS MFA (virtual MFA devices are standard SHA1 / 6-digit / 30s).
totp.py <base32-secret> # current 6-digit code
totp.py <secret> -w 1 # prev [current] next + seconds left
totp.py --secret-file - # read secret from stdin (keeps it out of ps)
TOTP_SECRET=$(pwsafe-get aws/mfa) totp.py # secret from the environment
totp.py <secret> --watch # live, refresh once per second
totp.py <secret> -t 59 -d 8 # RFC vector check -> 94287082
#!/usr/bin/env python3
"""Generate TOTP (RFC 6238) one-time codes from a base32 secret.
Pure standard library -- no pyotp / oathtool needed. Written for AWS MFA
(virtual MFA devices are standard TOTP: SHA1, 6 digits, 30s), but works for
any TOTP secret.
The secret can be given as the first argument (the default, most convenient
form; note argv is visible in `ps`). To keep it off the process list, use
instead --secret-file, $TOTP_SECRET, or the hidden prompt. Priority order:
1. first positional argument
2. --secret-file PATH (use '-' for stdin)
3. $TOTP_SECRET
4. interactive prompt (hidden)
The secret is never embedded in this file (this home dir is rsynced back).
Examples:
totp.py GEZDGNBVGY3TQOJQ # secret as first argument (default)
totp.py --secret-file ~/secret # or from a file ('-' = stdin)
TOTP_SECRET=$(pass aws/mfa) totp.py # or from the environment
totp.py GEZDGNBV... -w 1 # also show the previous/next codes
totp.py GEZDGNBV... --watch # live, refreshes each second
AWS use:
code=$(totp.py --secret-file ~/.aws-mfa.secret)
aws sts get-session-token \\
--serial-number arn:aws:iam::095171621201:mfa/<device> \\
--token-code "$code"
"""
from __future__ import annotations
import argparse
import base64
import getpass
import hashlib
import hmac
import os
import struct
import sys
import time
def _load_secret(args) -> str:
if args.secret:
raw = args.secret
elif args.secret_file:
if args.secret_file == "-":
raw = sys.stdin.read()
else:
with open(args.secret_file, encoding="utf-8") as fh:
raw = fh.read()
elif os.environ.get("TOTP_SECRET"):
raw = os.environ["TOTP_SECRET"]
elif sys.stdin.isatty():
raw = getpass.getpass("TOTP secret (base32): ")
else:
raise SystemExit("totp: no secret (use --secret-file, $TOTP_SECRET, or a terminal)")
return raw
def _decode_base32(secret: str) -> bytes:
# Normalise: strip spaces, uppercase, drop '=' then re-pad to a multiple of 8.
s = "".join(secret.split()).upper().rstrip("=")
pad = (-len(s)) % 8
try:
return base64.b32decode(s + "=" * pad)
except (ValueError, Exception) as exc: # binascii.Error subclasses vary
raise SystemExit(f"totp: not a valid base32 secret: {exc}")
_ALGOS = {"sha1": hashlib.sha1, "sha256": hashlib.sha256, "sha512": hashlib.sha512}
def totp(key: bytes, when: float, *, period: int, digits: int, algo: str) -> str:
counter = int(when // period)
mac = hmac.new(key, struct.pack(">Q", counter), _ALGOS[algo]).digest()
offset = mac[-1] & 0x0F
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
def main() -> int:
p = argparse.ArgumentParser(
description="Generate TOTP codes from a base32 secret (RFC 6238).",
epilog="The secret is read from --secret-file / $TOTP_SECRET / prompt, never argv.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("secret", nargs="?", help="base32 secret as the first argument (note: visible in `ps`)")
p.add_argument("--secret-file", metavar="PATH", help="file holding the base32 secret ('-' = stdin)")
p.add_argument("-d", "--digits", type=int, default=6, help="code length (default 6)")
p.add_argument("-p", "--period", type=int, default=30, help="time step seconds (default 30)")
p.add_argument("-a", "--algorithm", choices=_ALGOS, default="sha1", help="HMAC algorithm (default sha1)")
p.add_argument("-t", "--time", type=float, default=None, help="unix time to use (default now; for testing)")
p.add_argument("-w", "--window", type=int, default=0, help="also print N steps either side")
p.add_argument("--watch", action="store_true", help="refresh once per second until interrupted")
args = p.parse_args()
key = _decode_base32(_load_secret(args))
def emit(now: float) -> None:
remaining = args.period - int(now) % args.period
if args.window:
codes = [
totp(key, now + k * args.period, period=args.period,
digits=args.digits, algo=args.algorithm)
for k in range(-args.window, args.window + 1)
]
centre = args.window
codes[centre] = f"[{codes[centre]}]"
print(f"{' '.join(codes)} ({remaining}s left)")
else:
print(totp(key, now, period=args.period, digits=args.digits, algo=args.algorithm)
+ (f" ({remaining}s left)" if args.watch or sys.stdout.isatty() else ""))
if args.watch:
try:
while True:
emit(time.time())
time.sleep(1)
except KeyboardInterrupt:
return 0
else:
emit(args.time if args.time is not None else time.time())
return 0
if __name__ == "__main__":
sys.exit(main())