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 /test.py | |
| download | otp-master.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.
Diffstat (limited to 'test.py')
| -rwxr-xr-x | test.py | 123 |
1 files changed, 123 insertions, 0 deletions
@@ -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() |