Coverage for gws-app/gws/lib/otp/__init__.py: 96%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""Generate HOTP and TOTP tokens.
3References:
4 https://datatracker.ietf.org/doc/html/rfc4226
5 https://datatracker.ietf.org/doc/html/rfc6238
6"""
8from typing import Optional, cast
10import base64
11import hashlib
12import hmac
13import random
15import gws
16import gws.lib.net
19class Options(gws.Data):
20 start: int
21 step: int
22 length: int
23 tolerance: int
24 algo: str
27DEFAULTS = Options(
28 start=0,
29 step=30,
30 length=6,
31 tolerance=1,
32 algo='sha1',
33)
36def new_hotp(secret: str | bytes, counter: int, options: Optional[Options] = None) -> str:
37 """Generate a new HOTP value as per rfc4226 section 5.3."""
39 options = cast(Options, gws.u.merge(DEFAULTS, options))
40 return _raw_otp(_to_bytes(secret), counter, options)
43def new_totp(secret: str | bytes, timestamp: int, options: Optional[Options] = None) -> str:
44 """Generate a new TOTP value as per rfc6238 section 4.2."""
46 options = cast(Options, gws.u.merge(DEFAULTS, options))
47 counter = (timestamp - options.start) // options.step
48 return _raw_otp(_to_bytes(secret), counter, options)
51def check_totp(input: str, secret: str, timestamp: int, options: Optional[Options] = None) -> bool:
52 """Check if the input TOTP is valid.
54 Compares the input against several TOTPs within the tolerance window
55 ``(timestamp-step*tolerance...timestamp+step*tolerance)``.
56 """
58 options = cast(Options, gws.u.merge(DEFAULTS, options))
60 if len(input) != options.length:
61 return False
63 ok = False
65 for window in range(-options.tolerance, options.tolerance + 1):
66 ts = timestamp + options.step * window
67 counter = (ts - options.start) // options.step
68 totp = _raw_otp(_to_bytes(secret), counter, options)
69 if hmac.compare_digest(_to_bytes(input), _to_bytes(totp)):
70 ok = True
72 return ok
75def totp_key_uri(
76 secret: str | bytes,
77 issuer_name: str,
78 account_name: str,
79 options: Optional[Options] = None
80) -> str:
81 return _key_uri('totp', secret, issuer_name, account_name, None, options)
84def hotp_key_uri(
85 secret: str | bytes,
86 issuer_name: str,
87 account_name: str,
88 counter: int,
89 options: Optional[Options] = None
90) -> str:
91 return _key_uri('hotp', secret, issuer_name, account_name, counter, options)
94def _key_uri(
95 method: str,
96 secret: str | bytes,
97 issuer_name: str,
98 account_name: str,
99 counter: Optional[int] = None,
100 options: Optional[Options] = None
101) -> str:
102 """Create a key uri for auth apps.
104 Reference:
105 https://github.com/google/google-authenticator/wiki/Key-Uri-Format
106 """
108 params: dict = {
109 'secret': base32_encode(secret),
110 'issuer': issuer_name,
111 }
113 options = cast(Options, gws.u.merge(DEFAULTS, options))
115 if options.algo != DEFAULTS.algo:
116 params['algorithm'] = options.algo
117 if options.length != DEFAULTS.length:
118 params['digits'] = options.length
120 if method == 'hotp':
121 params['counter'] = counter
122 elif options.step != DEFAULTS.step:
123 params['period'] = options.step
125 return 'otpauth://{}/{}:{}?{}'.format(
126 method,
127 gws.lib.net.quote_param(issuer_name),
128 gws.lib.net.quote_param(account_name),
129 gws.lib.net.make_qs(params)
130 )
133def base32_decode(s: str) -> bytes:
134 return base64.b32decode(s)
137def base32_encode(s: str | bytes) -> str:
138 return base64.b32encode(_to_bytes(s)).decode('ascii')
141def random_secret(base32_length: int = 32) -> str:
142 """Generate a random printable secret that fits into base32_length."""
144 if (base32_length & 7) != 0:
145 raise ValueError('invalid length')
147 size = (base32_length >> 3) * 5
148 r = random.SystemRandom()
149 return ''.join(chr(r.randint(0x21, 0x7f)) for _ in range(size))
152##
154def _raw_otp(key: bytes, counter: int, options: Options) -> str:
155 # https://www.rfc-editor.org/rfc/rfc4226#section-5.3
156 #
157 # Step 1: Generate an HMAC-SHA-1 value
158 # Let HS = HMAC-SHA-1(K,C) // HS is a 20-byte string
159 #
160 # Step 2: Generate a 4-byte string (Dynamic Truncation)
161 # Let Sbits = DT(HS) // DT, defined below, returns a 31-bit string
162 #
163 # Let OffsetBits be the low-order 4 bits of String[19]
164 # Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
165 # Let P = String[OffSet]...String[OffSet+3]
166 # Return the Last 31 bits of P
167 #
168 # Let Snum = StToNum(Sbits) // Convert S to a number in 0...2^{31}-1
169 #
170 # Step 3: Compute an HOTP value
171 # Return D = Snum mod 10^Digit // D is a number in the range 0...10^{Digit}-1
173 c = counter.to_bytes(8, byteorder='big')
175 digestmod = getattr(hashlib, options.algo.lower())
176 hs = hmac.new(key, c, digestmod).digest()
178 offset = hs[-1] & 0xf
179 p = hs[offset:offset + 4]
180 snum = int.from_bytes(p, byteorder='big', signed=False) & 0x7fffffff
182 d = snum % (10 ** options.length)
184 return f'{d:0{options.length}d}'
187def _to_bytes(s):
188 return s.encode('utf8') if isinstance(s, str) else s
191def _option(options, key, default):
192 if not options:
193 return default
194 return getattr(options, key, default)