41 lines
1.4 KiB
Python
41 lines
1.4 KiB
Python
"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard
|
|
library. Compatible with Google Authenticator, Authy, 1Password, etc."""
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import struct
|
|
import time
|
|
from urllib.parse import quote
|
|
|
|
ISSUER = "MartinhalApprovalFlow"
|
|
|
|
|
|
def new_secret() -> str:
|
|
"""A new base32 secret to enroll in an authenticator app."""
|
|
return base64.b32encode(secrets.token_bytes(20)).decode()
|
|
|
|
|
|
def _code_at(secret: str, counter: int) -> str:
|
|
key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True)
|
|
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
|
offset = digest[-1] & 15
|
|
number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000
|
|
return f"{number:06d}"
|
|
|
|
|
|
def verify(secret: str, code: str) -> bool:
|
|
"""Check a 6-digit code, allowing one 30s step of clock drift each way."""
|
|
code = (code or "").strip().replace(" ", "")
|
|
if not (secret and code.isdigit() and len(code) == 6):
|
|
return False
|
|
counter = int(time.time() // 30)
|
|
return any(hmac.compare_digest(_code_at(secret, counter + drift), code)
|
|
for drift in (-1, 0, 1))
|
|
|
|
|
|
def otpauth_uri(username: str, secret: str) -> str:
|
|
"""URI encoded as a QR code for authenticator apps."""
|
|
return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}"
|
|
f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30")
|