From 14c1e3039f80a2fc50194b4da13a4f9964b7a1b3 Mon Sep 17 00:00:00 2001 From: Lena Date: Sun, 1 Mar 2026 00:00:00 +0000 Subject: Enter otp 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. --- README | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 README (limited to 'README') 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 -- cgit v1.2.3