aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-03-01 00:00:00 +0000
committerLena <lena@omega>2026-03-01 00:00:00 +0000
commit14c1e3039f80a2fc50194b4da13a4f9964b7a1b3 (patch)
tree3b28cb379bd19456cc166f48ec814c36fb0ecaa0
downloadotp-14c1e3039f80a2fc50194b4da13a4f9964b7a1b3.tar.gz
Enter otpHEADmaster
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--.gitignore2
-rw-r--r--README88
-rwxr-xr-xcli.py28
-rwxr-xr-xotp.py139
-rw-r--r--otp_secrets.example5
-rwxr-xr-xtest.py123
-rwxr-xr-xtui.py69
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
diff --git a/README b/README
new file mode 100644
index 0000000..66acfba
--- /dev/null
+++ b/README
@@ -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
diff --git a/cli.py b/cli.py
new file mode 100755
index 0000000..2ab5369
--- /dev/null
+++ b/cli.py
@@ -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())
diff --git a/otp.py b/otp.py
new file mode 100755
index 0000000..0565e46
--- /dev/null
+++ b/otp.py
@@ -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
diff --git a/test.py b/test.py
new file mode 100755
index 0000000..56d0380
--- /dev/null
+++ b/test.py
@@ -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()
diff --git a/tui.py b/tui.py
new file mode 100755
index 0000000..1f41dc9
--- /dev/null
+++ b/tui.py
@@ -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())