#!/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()