diff options
| author | Lena <lena@omega> | 2026-03-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-03-01 00:00:00 +0000 |
| commit | 14c1e3039f80a2fc50194b4da13a4f9964b7a1b3 (patch) | |
| tree | 3b28cb379bd19456cc166f48ec814c36fb0ecaa0 | |
| download | otp-14c1e3039f80a2fc50194b4da13a4f9964b7a1b3.tar.gz | |
Authenticator codes from the terminal, stdlib only, no phone and no
third-party dependency. Secrets are a private plain-text file, or the
output of a command so encrypted stores work unchanged.
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README | 88 | ||||
| -rwxr-xr-x | cli.py | 28 | ||||
| -rwxr-xr-x | otp.py | 139 | ||||
| -rw-r--r-- | otp_secrets.example | 5 | ||||
| -rwxr-xr-x | test.py | 123 | ||||
| -rwxr-xr-x | tui.py | 69 |
7 files changed, 454 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..644a26d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +otp_secrets @@ -0,0 +1,88 @@ +otp +=== +TOTP/HOTP codes from the command line. `otp.py` is the stdlib crypto core; +`cli.py` and `tui.py` read named secrets from a file and behave like an +authenticator. + + +Secrets file +------------ +Location is `$OTP_SECRETS`, default `~/.config/otp_secrets`. Keep it private: + + chmod 600 ~/.config/otp_secrets + +The file must be regular, owned by the current user, and have no group or +other permissions. Unsafe files are rejected. + +One entry per line, whitespace-delimited fields: + + name secret [period] [digits] [digest] + +`name` and `secret` (base32, single token) are required. Names may contain +ASCII letters, digits, `.`, `_`, `@`, `+` and `-`. `period` must be 1-3600 +seconds (default 30), `digits` must be 6-10 (default 6), and `digest` must be +sha1, sha256 or sha512 (default sha1). Blank lines and lines starting with `#` +are ignored. Duplicate names and extra fields are rejected. See +`otp_secrets.example`. + + +Encrypted secrets +----------------- +Optionally set `OTP_SECRETS_CMD` to any command that writes secrets-file +text to stdout; it is run with `sh -c` instead of reading `$OTP_SECRETS`, +so any secret store works: + + OTP_SECRETS_CMD='age -d ~/.config/otp_secrets.age' ./tui.py + OTP_SECRETS_CMD='gpg -qd ~/.config/otp_secrets.gpg' ./cli.py + OTP_SECRETS_CMD='pass show otp' ./cli.py github + +Passphrase prompts and errors go to the terminal; a non-zero exit aborts +with the command's exit code. To encrypt an existing file with age: + + age -p -o ~/.config/otp_secrets.age ~/.config/otp_secrets + + +Usage +----- +List a code for every entry: + + ./cli.py + +Print just one code (for scripting): + + ./cli.py github + +Live full-screen view with countdown bars; `q` quits, `r` reloads: + + ./tui.py + +Raw filter, no secrets file, reads base32 keys on stdin; optional positional +arguments are period, digits, digest: + + echo JBSWY3DPEHPK3PXP | ./otp.py + echo JBSWY3DPEHPK3PXP | ./otp.py 60 8 sha256 + + +Debug +----- +`test.py` asserts the RFC 4226 and RFC 6238 test vectors against the core, and +checks entry parsing, file permissions and the command path: + + ./test.py + +A named code must equal the raw filter for the same secret in the same window: + + ./cli.py github + echo JBSWY3DPEHPK3PXP | ./otp.py + +Point at a private throwaway file to test without touching your real secrets: + + umask 077 + TMP=$(mktemp) + printf 'demo JBSWY3DPEHPK3PXP\n' > "$TMP" + OTP_SECRETS="$TMP" ./cli.py demo + rm "$TMP" + +Exercise the command path without any encryption: + + OTP_SECRETS_CMD='printf "demo JBSWY3DPEHPK3PXP\n"' ./cli.py demo @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +import sys + +import otp + + +def main(): + if len(sys.argv) > 2: + sys.exit("usage: cli.py [name]") + try: + entries = otp.load_entries() + if len(sys.argv) > 1: + name = sys.argv[1] + for n, key, period, digits, digest in entries: + if n == name: + print(otp.totp(key, period, digits, digest)) + return + sys.exit(f"cli: no entry named {name!r}") + for name, key, period, digits, digest in entries: + print(name, otp.totp(key, period, digits, digest)) + except otp.SecretsError as error: + print(f"cli: {error}", file=sys.stderr) + return error.status + + +if __name__ == "__main__": + sys.exit(main()) @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import base64 +import hmac +import os +import re +import stat +import struct +import subprocess +import sys +import time + +# Names are printed to the terminal; control characters are excluded. +NAME_RE = re.compile(r"[A-Za-z0-9._@+-]+") +DIGESTS = ("sha1", "sha256", "sha512") +DEFAULT_SECRETS = "~/.config/otp_secrets" + + +class SecretsError(Exception): + def __init__(self, message, status=1): + super().__init__(message) + self.status = status + + +def decode_key(key): + if not key: + raise ValueError("secret must not be empty") + try: + decoded = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) + except ValueError as error: + raise ValueError("secret is not valid base32") from error + if not decoded: + raise ValueError("secret decodes to an empty key") + return decoded + + +def validate(period, digits, digest): + if not 1 <= period <= 3600: + raise ValueError("period must be between 1 and 3600") + if not 6 <= digits <= 10: + raise ValueError("digits must be between 6 and 10") + if digest not in DIGESTS: + raise ValueError(f"digest must be one of {', '.join(DIGESTS)}") + + +def hotp(key, counter, digits=6, digest="sha1"): + mac = hmac.new(decode_key(key), struct.pack(">Q", counter), digest).digest() + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF + return str(binary)[-digits:].zfill(digits) + + +def totp(key, period=30, digits=6, digest="sha1"): + return hotp(key, int(time.time()) // period, digits, digest) + + +def secrets_text(): + cmd = os.environ.get("OTP_SECRETS_CMD") + if cmd: + proc = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) + if proc.returncode != 0: + raise SecretsError("secrets command failed", proc.returncode) + output = proc.stdout + source = "OTP_SECRETS_CMD" + else: + path = os.environ.get("OTP_SECRETS") or os.path.expanduser(DEFAULT_SECRETS) + try: + with open(path, "rb") as f: + info = os.fstat(f.fileno()) + if not stat.S_ISREG(info.st_mode): + raise SecretsError(f"{path}: not a regular file") + if info.st_uid != os.geteuid(): + raise SecretsError(f"{path}: not owned by the current user") + if info.st_mode & 0o077: + raise SecretsError(f"{path}: permissions must be 0600 or stricter") + output = f.read() + except OSError as error: + raise SecretsError(f"{path}: {error.strerror}") from None + source = path + try: + return output.decode(), source + except UnicodeDecodeError: + raise SecretsError(f"{source}: not valid UTF-8") from None + + +def parse_entries(text, source): + entries = [] + names = set() + for lineno, line in enumerate(text.splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + fields = line.split() + if not 2 <= len(fields) <= 5: + raise SecretsError(f"{source}:{lineno}: expected 2-5 fields") + name, key = fields[0], fields[1] + if not NAME_RE.fullmatch(name): + raise SecretsError(f"{source}:{lineno}: invalid name {name!r}") + if name in names: + raise SecretsError(f"{source}:{lineno}: duplicate name {name!r}") + try: + period = int(fields[2]) if len(fields) > 2 else 30 + digits = int(fields[3]) if len(fields) > 3 else 6 + except ValueError: + raise SecretsError( + f"{source}:{lineno}: period and digits must be integers" + ) from None + digest = fields[4].lower() if len(fields) > 4 else "sha1" + try: + validate(period, digits, digest) + decode_key(key) + except ValueError as error: + raise SecretsError(f"{source}:{lineno}: {error}") from None + entries.append((name, key, period, digits, digest)) + names.add(name) + return entries + + +def load_entries(): + text, source = secrets_text() + return parse_entries(text, source) + + +def main(): + if len(sys.argv) > 4: + sys.exit("usage: otp.py [period [digits [digest]]]") + try: + period = int(sys.argv[1]) if len(sys.argv) > 1 else 30 + digits = int(sys.argv[2]) if len(sys.argv) > 2 else 6 + digest = sys.argv[3].lower() if len(sys.argv) > 3 else "sha1" + validate(period, digits, digest) + for key in sys.stdin: + print(totp(key.strip(), period, digits, digest)) + except ValueError as error: + sys.exit(f"otp: {error}") + + +if __name__ == "__main__": + main() diff --git a/otp_secrets.example b/otp_secrets.example new file mode 100644 index 0000000..7e0b55a --- /dev/null +++ b/otp_secrets.example @@ -0,0 +1,5 @@ +# name secret [period] [digits] [digest] +# Defaults: period 30, digits 6, digest sha1. + +github JBSWY3DPEHPK3PXP +bank KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD 60 8 sha256 @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +import base64 +import os +import subprocess +import tempfile + +import otp + +SEED20 = base64.b32encode(b"12345678901234567890").decode() +SEED32 = base64.b32encode(b"12345678901234567890123456789012").decode() +SEED64 = base64.b32encode( + b"1234567890123456789012345678901234567890123456789012345678901234" +).decode() + +# RFC 4226 appendix D, counters 0-9. +HOTP = "755224 287082 359152 969429 338314 254676 287922 162583 399871 520489".split() + +# RFC 6238 appendix B, 8 digits: time -> (sha1, sha256, sha512). +TOTP = { + 59: ("94287082", "46119246", "90693936"), + 1111111109: ("07081804", "68084774", "25091201"), + 1111111111: ("14050471", "67062674", "99943326"), + 1234567890: ("89005924", "91819424", "93441116"), + 2000000000: ("69279037", "90698825", "38618901"), + 20000000000: ("65353130", "77737706", "47863826"), +} + + +def check(got, want, label): + if got != want: + raise SystemExit(f"{label}: got {got!r}, want {want!r}") + + +def fails(call, message): + try: + call() + except (otp.SecretsError, ValueError) as error: + if message not in str(error): + raise SystemExit(f"wrong error: {error!r}") from error + return + raise SystemExit(f"expected error containing {message!r}") + + +def test_vectors(): + for counter, want in enumerate(HOTP): + check(otp.hotp(SEED20, counter), want, f"HOTP counter {counter}") + for t, (sha1, sha256, sha512) in TOTP.items(): + check(otp.hotp(SEED20, t // 30, 8, "sha1"), sha1, f"SHA-1 time {t}") + check(otp.hotp(SEED32, t // 30, 8, "sha256"), sha256, f"SHA-256 time {t}") + check(otp.hotp(SEED64, t // 30, 8, "sha512"), sha512, f"SHA-512 time {t}") + + +def test_validation(): + key = "JBSWY3DPEHPK3PXP" + entries = otp.parse_entries(f"demo {key}\n", "test") + check(entries, [("demo", key, 30, 6, "sha1")], "default entry") + + invalid = ( + ("broken\n", "expected 2-5 fields"), + (f"demo {key} 0\n", "period must be between"), + (f"demo {key} 30 5\n", "digits must be between"), + (f"demo {key} 30 6 md5\n", "digest must be one of"), + (f"demo {key} 30 6 sha1 extra\n", "expected 2-5 fields"), + ("demo invalid!\n", "secret is not valid base32"), + (f"bad\x1bname {key}\n", "invalid name"), + (f"demo {key}\ndemo {key}\n", "duplicate name"), + ) + for text, message in invalid: + fails(lambda text=text: otp.parse_entries(text, "test"), message) + + fails(lambda: otp.hotp("", 0), "secret must not be empty") + + +def test_file_permissions(): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("demo JBSWY3DPEHPK3PXP\n") + path = f.name + try: + os.chmod(path, 0o644) + env = os.environ.copy() + env.pop("OTP_SECRETS_CMD", None) + env["OTP_SECRETS"] = path + proc = subprocess.run(["./cli.py"], env=env, capture_output=True, text=True) + check(proc.returncode, 1, "unsafe file exit status") + want = f"cli: {path}: permissions must be 0600 or stricter\n" + check(proc.stderr, want, "unsafe file error") + finally: + os.unlink(path) + + +def test_commands(): + env = os.environ.copy() + env["OTP_SECRETS_CMD"] = "exit 42" + proc = subprocess.run(["./cli.py"], env=env, capture_output=True, text=True) + check(proc.returncode, 42, "secret command exit status") + check(proc.stderr, "cli: secrets command failed\n", "secret command error") + + proc = subprocess.run(["./tui.py"], env=env, capture_output=True, text=True) + check(proc.returncode, 42, "TUI secret command exit status") + check(proc.stderr, "tui: secrets command failed\n", "TUI secret command error") + + proc = subprocess.run(["./otp.py"], input="\n", capture_output=True, text=True) + check(proc.returncode, 1, "blank raw key exit status") + check(proc.stderr, "otp: secret must not be empty\n", "blank raw key error") + + proc = subprocess.run( + ["./otp.py", "30", "5"], input="", capture_output=True, text=True + ) + check(proc.returncode, 1, "raw digits exit status") + check(proc.stderr, "otp: digits must be between 6 and 10\n", "raw digits error") + + +def main(): + test_vectors() + test_validation() + test_file_permissions() + test_commands() + print("ok") + + +if __name__ == "__main__": + main() @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import curses +import sys +import time + +import otp + +BAR_WIDTH = 20 + + +def put(stdscr, row, text): + maxy, maxx = stdscr.getmaxyx() + if row >= maxy: + return + try: + stdscr.addstr(row, 0, text[: maxx - 1]) + except curses.error: # the terminal can shrink between getmaxyx and addstr + pass + + +def draw(stdscr, entries, message=None): + stdscr.erase() + now = int(time.time()) + width = max((len(name) for name, *_ in entries), default=0) + cwidth = max((digits for _, _, _, digits, _ in entries), default=0) + for row, (name, key, period, digits, digest) in enumerate(entries): + code = otp.hotp(key, now // period, digits, digest) + remaining = period - now % period + filled = remaining * BAR_WIDTH // period + bar = "#" * filled + "-" * (BAR_WIDTH - filled) + put(stdscr, row, f"{name:<{width}} {code:>{cwidth}} {remaining:2d}s [{bar}]") + put(stdscr, len(entries) + 1, message or "q quit r reload") + stdscr.refresh() + + +def run(stdscr, entries): + curses.curs_set(0) + stdscr.timeout(250) + message = None + while True: + draw(stdscr, entries, message) + ch = stdscr.getch() + if ch in (ord("q"), 27): + break + if ch == ord("r"): + curses.endwin() # OTP_SECRETS_CMD may prompt on the terminal + try: + new_entries = otp.load_entries() + except otp.SecretsError as error: + message = f"reload failed: {error}" + else: + entries = new_entries + message = None + + +def main(): + try: + entries = otp.load_entries() + curses.wrapper(run, entries) + except otp.SecretsError as error: + print(f"tui: {error}", file=sys.stderr) + return error.status + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |