From dd002d03691cf624ce7eb6a4ebd533254aa37ee5 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Sat, 27 Dec 2025 18:21:00 +0200 Subject: [PATCH] OTP functionality implemented --- README.md | 10 +++ code.py | 2 + deck/otp.py | 183 +++++++++++++++++++++++++++++++++++++++++++++++ deck/time.py | 4 +- requirements.txt | 1 + tdeckboot.py | 1 + 6 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 deck/otp.py diff --git a/README.md b/README.md index acddfd8..902a2d9 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,16 @@ Exports the following functions: - `wget(url, filename='', useragent=None, hdrs={})`: download a file into a local path (returns `(status_code, filename)` tuple) - `wput(url, file_path, field_name="file", method="POST", format="x-www-form-urlencoded", headers={}, useragent=None)`: upload a file from a local path in the `x-www-form-urlencoded` or JSON format (returns `(status_code, content)` tuple) +### `deck.otp` + +A library that provides HOTP and TOTP one-time password implementations. + +Exports the following functions: + +- `hotp(key, counter, digits=6)`: generate a `digits`-long HOTP code from (bytes-only) key and counter value +- `totp(key, time_step, digits=6)`: generate a `digits`-long TOTP code from (bytes-only) key, using `time_step` as the basis (30 seconds is default and the most common) +- `get_epoch()`: a helper platform-agnostic function to get the current Unix timestamp in seconds + ### `deck.llm` A helper library for interaction with remote LLM (large language model) endpoints that expose OpenAI-compatible API. As of now, it only exports a single class, `LLMChat`, to encapsulate all the interaction. diff --git a/code.py b/code.py index 9174671..d72a270 100644 --- a/code.py +++ b/code.py @@ -28,6 +28,8 @@ while True: status, res = runcode(__cmd) if status != 1: __cmd = "" + if status == 0: + print("*ERROR* Exception:", str(res)) except Exception as __err: print("*ERROR* Exception:", str(__err)) __cmd = "" diff --git a/deck/otp.py b/deck/otp.py new file mode 100644 index 0000000..3e208f7 --- /dev/null +++ b/deck/otp.py @@ -0,0 +1,183 @@ +# deck.otp: HOTP/TOTP implementation for T-DeckARD +# Initially based on the utotp code +# Should be compatible with CircuitPython, MicroPython and CPython +# On CircuitPython, depends on adafruit_hashlib +# Usage: from deck.otp import hotp, totp +# Created by Luxferre in 2025, released into public domain + +try: + import ustruct as struct +except ImportError: + import struct + +try: + from adafruit_hashlib import sha1 +except ImportError: + from hashlib import sha1 + +FORCE_MPY = False +try: + from deck.time import datetime, timedelta +except ImportError: + try: + from datetime import datetime, timedelta + except ImportError: + from time import time + FORCE_MPY = True + +class Sha1HMAC: + def __init__(self, key, msg=None): + def translate(d, t): + return bytes(t[x] for x in d) + _trans_5C = bytes((x ^ 0x5C) for x in range(256)) + _trans_36 = bytes((x ^ 0x36) for x in range(256)) + self.hasher = sha1 + self.outer = self.hasher() + self.inner = self.hasher() + self.digest_size = 20 # // hashlib.sha1().digest_size + self.blocksize = 64 + if len(key) > self.blocksize: + key = self.hasher(key).digest() + key = key + bytes(self.blocksize - len(key)) + self.outer.update(translate(key, _trans_5C)) + self.inner.update(translate(key, _trans_36)) + if msg is not None: + self.update(msg) + + def update(self, msg): + self.inner.update(msg) + + def _current(self): + self.outer.update(self.inner.digest()) + return self.outer + + def digest(self): + return self._current().digest() + +def get_epoch(): + maybe_time = 0 + if FORCE_MPY: + maybe_time = time() + if maybe_time < 946684801: + maybe_time += 946684801 + else: + maybe_time = datetime.now() + td = timedelta(seconds=946684801) + ref_time = datetime(1970, 1, 1) + td + if maybe_time < ref_time: + maybe_time += td + try: + from deck.net import init_ntp + ntpobj = init_ntp() + maybe_time = int(str(ntpobj.utc_ns)[:-9]) + except Exception as e: + pass + try: + timeval = int(maybe_time) + except: + timeval = int(maybe_time.timestamp()) + return timeval + +def hotp(key, counter, digits=6): + counter = struct.pack(">Q", counter) + mac = Sha1HMAC(key, counter).digest() + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF + code = str(binary)[-digits:] + return ((digits - len(code)) * "0") + code + +def totp(key, time_step=30, digits=6): + if isinstance(key, str): + try: + key = b32decode(key) + except: + raise ValueError("Key must be b32 string or seed bytes") + return hotp(key, get_epoch() // time_step, digits) + +_b32alphabet = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' +_b32tab = [v for v in _b32alphabet] +_b32rev = dict([(v, k) for k,v in enumerate(_b32tab)]) + +def unhexlify(data): + if len(data) % 2 != 0: + raise ValueError("Odd-length string") + return bytes([int(data[i : i + 2], 16) for i in range(0, len(data), 2)]) + +def b32encode(s): + if not isinstance(s, bytes_types): + raise TypeError("expected bytes, not %s" % s.__class__.__name__) + quanta, leftover = divmod(len(s), 5) + # Pad the last quantum with zero bits if necessary + if leftover: + s = s + bytes(5 - leftover) # Don't use += ! + quanta += 1 + encoded = bytearray() + for i in range(quanta): + c1, c2, c3 = struct.unpack("!HHB", s[i * 5 : (i + 1) * 5]) + c2 += (c1 & 1) << 16 + c3 += (c2 & 3) << 8 + encoded += bytes( + [ + _b32tab[c1 >> 11], + _b32tab[(c1 >> 6) & 0x1F], + _b32tab[(c1 >> 1) & 0x1F], + _b32tab[c2 >> 12], + _b32tab[(c2 >> 7) & 0x1F], + _b32tab[(c2 >> 2) & 0x1F], + _b32tab[c3 >> 5], + _b32tab[c3 & 0x1F], + ] + ) + if leftover == 1: + encoded = encoded[:-6] + b"======" + elif leftover == 2: + encoded = encoded[:-4] + b"====" + elif leftover == 3: + encoded = encoded[:-3] + b"===" + elif leftover == 4: + encoded = encoded[:-1] + b"=" + return bytes(encoded) + +def b32decode(s): + if isinstance(s, str): + s = s.encode() + quanta, leftover = divmod(len(s), 8) + if leftover: + raise ValueError("Incorrect padding") + s = s.upper() + padchars = s.find(b"=") + if padchars > 0: + padchars = len(s) - padchars + s = s[:-padchars] + else: + padchars = 0 + # Now decode the full quanta + parts = [] + acc = 0 + shift = 35 + for c in s: + val = _b32rev.get(c) + if val is None: + raise ValueError("Non-base32 digit found") + acc += _b32rev[c] << shift + shift -= 5 + if shift < 0: + parts.append(unhexlify(bytes("%010x" % acc, "ascii"))) + acc = 0 + shift = 35 + # Process the last, partial quanta + last = unhexlify(bytes("%010x" % acc, "ascii")) + if padchars == 0: + last = b"" # No characters + elif padchars == 1: + last = last[:-1] + elif padchars == 3: + last = last[:-2] + elif padchars == 4: + last = last[:-3] + elif padchars == 6: + last = last[:-4] + else: + raise ValueError("Incorrect padding") + parts.append(last) + return b"".join(parts) diff --git a/deck/time.py b/deck/time.py index d9fc096..49fe2b1 100644 --- a/deck/time.py +++ b/deck/time.py @@ -3,6 +3,6 @@ # Created by Luxferre in 2025, released into public domain try: - from adafruit_datetime import datetime, date, time + from adafruit_datetime import datetime, date, time, timedelta except: - import datetime, date, time + from datetime import datetime, date, time, timedelta diff --git a/requirements.txt b/requirements.txt index dd5215d..51a5275 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ adafruit_requests>=4.1.15 adafruit_datetime>=1.4.4 adafruit_ntp>=3.3.5 adafruit_bus_device>=5.2.14 +adafruit_hashlib>=1.4.20 diff --git a/tdeckboot.py b/tdeckboot.py index 47fd112..c1cf2e1 100644 --- a/tdeckboot.py +++ b/tdeckboot.py @@ -11,6 +11,7 @@ from deck.time import * from deck.hwinfo import board_id, battery_v, cpu_f, REAL_HW from deck.http import wget, wput, http_fetch, http_set_ua from deck.http import auth_header, url_escape, form_encode +from deck.otp import totp, hotp, get_epoch from deck import xlat from app.ed import edit, view, edbuf, viewbuf, edurl, viewurl from app.blog import blogpost, blogpost_str, bloged