aboutsummaryrefslogtreecommitdiff
path: root/test.py
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 /test.py
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.
Diffstat (limited to 'test.py')
-rwxr-xr-xtest.py123
1 files changed, 123 insertions, 0 deletions
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()