314 lines
9.2 KiB
Python
314 lines
9.2 KiB
Python
# SPDX-FileCopyrightText: 2017 Limor Fried for Adafruit Industries
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
import time
|
|
|
|
import board
|
|
import busio
|
|
from digitalio import DigitalInOut
|
|
import displayio
|
|
import terminalio
|
|
from simpleio import map_range
|
|
import adafruit_hashlib as hashlib
|
|
import adafruit_touchscreen
|
|
from adafruit_button import Button
|
|
from adafruit_progressbar.progressbar import ProgressBar
|
|
from adafruit_display_text.label import Label
|
|
from adafruit_esp32spi import adafruit_esp32spi
|
|
from adafruit_pyportal import PyPortal
|
|
import rtc
|
|
|
|
|
|
# Background Color
|
|
BACKGROUND = 0x0
|
|
|
|
# Button color
|
|
BTN_COLOR = 0xFFFFFF
|
|
|
|
# Button text color
|
|
BTN_TEXT_COLOR = 0x0
|
|
|
|
# Set to true if you never want to go to sleep!
|
|
ALWAYS_ON = True
|
|
|
|
# How long to stay on if not in always_on mode
|
|
ON_SECONDS = 60
|
|
|
|
# Get wifi details and more from a secrets.py file
|
|
try:
|
|
from secrets import secrets
|
|
except ImportError:
|
|
print("WiFi secrets are kept in secrets.py, please add them there!")
|
|
raise
|
|
|
|
# Initialize PyPortal Display
|
|
display = board.DISPLAY
|
|
|
|
WIDTH = board.DISPLAY.width
|
|
HEIGHT = board.DISPLAY.height
|
|
ts = adafruit_touchscreen.Touchscreen(board.TOUCH_XL, board.TOUCH_XR,
|
|
board.TOUCH_YD, board.TOUCH_YU,
|
|
calibration=(
|
|
(5200, 59000),
|
|
(5800, 57000)
|
|
),
|
|
size=(WIDTH, HEIGHT))
|
|
|
|
# Create a SHA1 Object
|
|
SHA1 = hashlib.sha1
|
|
|
|
# PyPortal ESP32 AirLift Pins
|
|
esp32_cs = DigitalInOut(board.ESP_CS)
|
|
esp32_ready = DigitalInOut(board.ESP_BUSY)
|
|
esp32_reset = DigitalInOut(board.ESP_RESET)
|
|
|
|
# Initialize PyPortal ESP32 AirLift
|
|
spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
|
|
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
|
|
|
|
def HMAC(k, m):
|
|
"""# HMAC implementation, as hashlib/hmac wouldn't fit
|
|
From https://en.wikipedia.org/wiki/Hash-based_message_authentication_code
|
|
|
|
"""
|
|
SHA1_BLOCK_SIZE = 64
|
|
KEY_BLOCK = k + (b'\0' * (SHA1_BLOCK_SIZE - len(k)))
|
|
KEY_INNER = bytes((x ^ 0x36) for x in KEY_BLOCK)
|
|
KEY_OUTER = bytes((x ^ 0x5C) for x in KEY_BLOCK)
|
|
inner_message = KEY_INNER + m
|
|
outer_message = KEY_OUTER + SHA1(inner_message).digest()
|
|
return SHA1(outer_message)
|
|
|
|
def base32_decode(encoded):
|
|
missing_padding = len(encoded) % 8
|
|
if missing_padding != 0:
|
|
encoded += '=' * (8 - missing_padding)
|
|
encoded = encoded.upper()
|
|
chunks = [encoded[i:i + 8] for i in range(0, len(encoded), 8)]
|
|
|
|
out = []
|
|
for chunk in chunks:
|
|
bits = 0
|
|
bitbuff = 0
|
|
for c in chunk:
|
|
if 'A' <= c <= 'Z':
|
|
n = ord(c) - ord('A')
|
|
elif '2' <= c <= '7':
|
|
n = ord(c) - ord('2') + 26
|
|
elif c == '=':
|
|
continue
|
|
else:
|
|
raise ValueError("Not base32")
|
|
# 5 bits per 8 chars of base32
|
|
bits += 5
|
|
# shift down and add the current value
|
|
bitbuff <<= 5
|
|
bitbuff |= n
|
|
# great! we have enough to extract a byte
|
|
if bits >= 8:
|
|
bits -= 8
|
|
byte = bitbuff >> bits # grab top 8 bits
|
|
bitbuff &= ~(0xFF << bits) # and clear them
|
|
out.append(byte) # store what we got
|
|
return out
|
|
|
|
def int_to_bytestring(int_val, padding=8):
|
|
result = []
|
|
while int_val != 0:
|
|
result.insert(0, int_val & 0xFF)
|
|
int_val >>= 8
|
|
result = [0] * (padding - len(result)) + result
|
|
return bytes(result)
|
|
|
|
|
|
def generate_otp(int_input, secret_key, digits=6):
|
|
""" HMAC -> OTP generator, pretty much same as
|
|
https://github.com/pyotp/pyotp/blob/master/src/pyotp/otp.py
|
|
|
|
"""
|
|
if int_input < 0:
|
|
raise ValueError('input must be positive integer')
|
|
hmac_hash = bytearray(
|
|
HMAC(bytes(base32_decode(secret_key)),
|
|
int_to_bytestring(int_input)).digest()
|
|
)
|
|
offset = hmac_hash[-1] & 0xf
|
|
code = ((hmac_hash[offset] & 0x7f) << 24 |
|
|
(hmac_hash[offset + 1] & 0xff) << 16 |
|
|
(hmac_hash[offset + 2] & 0xff) << 8 |
|
|
(hmac_hash[offset + 3] & 0xff))
|
|
str_code = str(code % 10 ** digits)
|
|
while len(str_code) < digits:
|
|
str_code = '0' + str_code
|
|
|
|
return str_code
|
|
|
|
def display_otp_key(secret_name, secret_otp):
|
|
"""Updates the displayio labels to display formatted OTP key and name.
|
|
|
|
"""
|
|
# display the key's name
|
|
label_title.text = secret_name
|
|
# format and display the OTP
|
|
label_secret.text = "{} {}".format(str(secret_otp)[0:3], str(secret_otp)[3:6])
|
|
print("OTP Name: {}\nOTP Key: {}".format(secret_name, secret_otp))
|
|
|
|
print("===========================================")
|
|
|
|
# GFX Font
|
|
font = terminalio.FONT
|
|
|
|
# Initialize new PyPortal object
|
|
pyportal = PyPortal(esp=esp,
|
|
external_spi=spi)
|
|
|
|
# Root DisplayIO
|
|
root_group = displayio.Group()
|
|
display.show(root_group)
|
|
|
|
BACKGROUND = BACKGROUND if isinstance(BACKGROUND, int) else 0x0
|
|
bg_bitmap = displayio.Bitmap(display.width, display.height, 1)
|
|
bg_palette = displayio.Palette(1)
|
|
bg_palette[0] = BACKGROUND
|
|
background = displayio.TileGrid(bg_bitmap, pixel_shader=bg_palette)
|
|
|
|
# Create a new DisplayIO group
|
|
splash = displayio.Group()
|
|
|
|
splash.append(background)
|
|
|
|
key_group = displayio.Group(scale=5)
|
|
# We'll use a default text placeholder for this label
|
|
label_secret = Label(font, text="000 000")
|
|
label_secret.x = (display.width // 2) // 13
|
|
label_secret.y = 17
|
|
key_group.append(label_secret)
|
|
|
|
label_title = Label(font)
|
|
label_title.text = " Loading.."
|
|
label_title.x = 0
|
|
label_title.y = 5
|
|
key_group.append(label_title)
|
|
|
|
# append key_group to splash
|
|
splash.append(key_group)
|
|
|
|
# Show the group
|
|
display.show(splash)
|
|
|
|
print("Connecting to AP...")
|
|
while not esp.is_connected:
|
|
try:
|
|
esp.connect_AP(secrets['ssid'], secrets['password'])
|
|
except RuntimeError as e:
|
|
print("Could not connect to AP, retrying: ", e)
|
|
continue
|
|
|
|
print("Connected to ", secrets['ssid'])
|
|
|
|
# get_time will raise ValueError if the time isn't available yet so loop until
|
|
# it works.
|
|
now_utc = None
|
|
while now_utc is None:
|
|
try:
|
|
now_utc = time.localtime(esp.get_time()[0])
|
|
except ValueError:
|
|
pass
|
|
rtc.RTC().datetime = now_utc
|
|
|
|
# Get the current time in seconds since Jan 1, 1970
|
|
t = time.time()
|
|
print("Seconds since Jan 1, 1970: {} seconds".format(t))
|
|
|
|
# Instead of using RTC which means converting back and forth
|
|
# we'll just keep track of seconds-elapsed-since-NTP-call
|
|
mono_time = int(time.monotonic())
|
|
print("Monotonic time", mono_time)
|
|
|
|
# Add buttons to the interface
|
|
assert len(secrets['totp_keys']) < 6, "This code can only display 5 keys at a time"
|
|
|
|
# generate buttons
|
|
buttons = []
|
|
|
|
btn_x = 5
|
|
for i in secrets['totp_keys']:
|
|
button = Button(name=i[0], x=btn_x,
|
|
y=175, width=60,
|
|
height=60, label=i[0].strip(" "),
|
|
label_font=font, label_color=BTN_TEXT_COLOR,
|
|
fill_color=BTN_COLOR, style=Button.ROUNDRECT)
|
|
buttons.append(button)
|
|
# add padding btween buttons
|
|
btn_x += 63
|
|
|
|
# append buttons to splash group
|
|
for b in buttons:
|
|
splash.append(b)
|
|
|
|
# refrsh timer label
|
|
label_timer = Label(font)
|
|
label_timer.x = (display.width // 2) // 13
|
|
label_timer.y = 15
|
|
splash.append(label_timer)
|
|
|
|
# create a new progress bar
|
|
progress_bar = ProgressBar(display.width//5, 125,
|
|
200, 30, bar_color = 0xAAAAAA)
|
|
|
|
splash.append(progress_bar)
|
|
|
|
# how long to stay on if not in always_on mode
|
|
countdown = ON_SECONDS
|
|
|
|
# current button state, defaults to first item in totp_keys
|
|
current_button = secrets['totp_keys'][0][0]
|
|
buttons[0].selected = True
|
|
|
|
while ALWAYS_ON or (countdown > 0):
|
|
# Calculate current time based on NTP + monotonic
|
|
unix_time = t - mono_time + int(time.monotonic())
|
|
|
|
# Update the key refresh timer
|
|
timer = time.localtime(time.time()).tm_sec
|
|
# timer resets on :00/:30
|
|
if timer > 30:
|
|
countdown = 60 - timer
|
|
else:
|
|
countdown = 30 - timer
|
|
print('NTP Countdown: {}%'.format(countdown))
|
|
# change the timer bar's color if text is about to refresh
|
|
progress_bar.fill = 0xFFFFFF
|
|
if countdown < 5:
|
|
progress_bar.fill = 0xFF0000
|
|
|
|
# update the progress_bar with countdown
|
|
countdown = map_range(countdown, 0, 30, 0.0, 1.0)
|
|
progress_bar.progress = countdown
|
|
|
|
# poll the touchscreen
|
|
p = ts.touch_point
|
|
# if the touchscreen was pressed
|
|
if p:
|
|
for i, b in enumerate(buttons):
|
|
if b.contains(p):
|
|
b.selected = True
|
|
for name, secret in secrets['totp_keys']:
|
|
# check if button name is the same as a key name
|
|
if b.name == name:
|
|
current_button = name
|
|
# Generate OTP
|
|
otp = generate_otp(unix_time // 30, secret)
|
|
display_otp_key(name, otp)
|
|
else:
|
|
b.selected = False
|
|
else:
|
|
for name, secret in secrets['totp_keys']:
|
|
if current_button == name:
|
|
# Generate OTP
|
|
otp = generate_otp(unix_time // 30, secret)
|
|
display_otp_key(name, otp)
|
|
# We'll update every 1/4 second, we can hash very fast so its no biggie!
|
|
countdown -= 0.25
|
|
time.sleep(0.25)
|