Last active
May 3, 2025 00:24
-
-
Save ardnew/1ddfadacbe8252820c7905053f1a631e to your computer and use it in GitHub Desktop.
Adafruit Macropad - HID Keyboard - Unlock screen with password(s) on Windows Domain
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Password Macropad - Unlock Windows workstations with obnoxious | |
domain password requirements using only a PIN | |
No user account information is stored plain-text in the sources — | |
it is all AES encrypted and decrypted at runtime on-demand. | |
See secrets.py. | |
Each entry in secrets['accounts'] appears as a single row on the | |
Macropad. Pressing the key in the first column will simulate | |
typing the full credentials string (which shold be "<user>\t<pass>", | |
without the carets <|>), the second column will type the username, | |
and the third column will type the password. A newline "\n" is also | |
appended in all cases to simulate pressing the "Enter" key. The tab | |
character typed in the first case will cause the cursor to navigate | |
from the username -> password field automatically. | |
""" | |
# Based on Scramblepad by Adafruit. The original license follows: | |
# | |
# | # SPDX-FileCopyrightText: 2021 Anne Barela for Adafruit Industries | |
# | # | |
# | # SPDX-License-Identifier: MIT | |
# | | |
# | """ | |
# | Scramblepad - a random scramble keypad simulation for Adafruit MACROPAD. | |
# | """ | |
# | # SPDX-FileCopyrightText: Copyright (c) 2021 Anne Barela for Adafruit Industries | |
# | # | |
# | # SPDX-License-Identifier: MIT | |
import time | |
import random | |
import board | |
import adafruit_hashlib as hashlib | |
from digitalio import DigitalInOut, Direction | |
import displayio | |
import terminalio | |
from adafruit_display_shapes.rect import Rect | |
from adafruit_display_text import label | |
from adafruit_macropad import MacroPad | |
from collections import deque | |
from secrets import skullkey, secrets, Cipher, UserAccount | |
# CONFIGURABLES ------------------------ | |
# Maximum length of password: | |
# (Reset if this many keypresses are made without producing the correct digest) | |
PASSWORD_MAXLEN = 8 | |
# States keypad may be in | |
STATE_RESET = 0 | |
STATE_ENTRY = 1 | |
STATE_ULOCK = 2 | |
STATE_READY = 3 | |
# The total number of keys on the keypad | |
KEY_COUNT = 12 | |
# Some special key indexes | |
KEY_STATE = 11 | |
KEY_QUEUE = 10 | |
KEY_INPUT = 9 | |
# Time in seconds to remain unlocked after correct password | |
UNLOCK_DURATION = 60 * 60 * 4 # 4 hours | |
LOCK_WARNING = 60 * 5 # 5 minutes before lock | |
# Color defines for keys | |
WHITE = 0xFFFFFF | |
BLACK = 0x000000 | |
RED = 0xFF0000 | |
ORANGE = 0xFFA500 | |
YELLOW = 0xFFFF00 | |
GREEN = 0x00FF00 | |
BLUE = 0x0000FF | |
PURPLE = 0x800080 | |
PINK = 0xFFC0CB | |
TEAL = 0x2266AA | |
MAGENTA = 0xFF00FF | |
CYAN = 0x00FFFF | |
#colors = [PINK, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE, TEAL, MAGENTA, CYAN] | |
colors = [BLACK, BLACK, BLACK, BLACK, BLACK, BLACK, BLACK, BLACK, BLACK, BLACK] | |
current_colors = [] | |
# Define sounds the keypad makes | |
tones = (440, 220, 245, 330, 440) # Initial tones while scrambling | |
press_tone = 660 # This tone is used when each key is pressed | |
tone_enable = true | |
# Initial key values - this list will be scrambled by the scramble function | |
key_place = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] | |
key_digit = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | |
num_digit = len(key_digit) | |
max_digit = key_digit[-1] | |
def printable(text): | |
return ''.join(c for c in text if 0x1F<ord(c)<0x7F or c in "\t\n") | |
def keys_display(): # Display the current values of the keys on screen | |
for k in range(max_digit): # The first 9 keys | |
group[k].text = str(key_digit[k]) | |
macropad.pixels[k] = colors[key_digit[k]] | |
group[num_digit].text = str(key_digit[max_digit]) # The 'Zero' position number | |
macropad.pixels[num_digit] = colors[key_digit[max_digit]] | |
macropad.display.refresh() | |
macropad.pixels.show() | |
def shuffle(items, handle = None, count = 5): | |
for times in range(count): | |
# The following lines implement a random.shuffle method | |
# See https://www.rosettacode.org/wiki/Knuth_shuffle#Python | |
# random.shuffle(items) # Shuffle the key array | |
for i in range(len(items)-1, 0, -1): | |
j = random.randrange(i + 1) | |
items[i], items[j] = items[j], items[i] | |
if handle is not None: | |
handle(items, times) | |
return items | |
def on_key_shuffle(keys, iteration): | |
keys_display() | |
if tone_enable: | |
macropad.play_tone(tones[iteration], 0.1) | |
time.sleep(0.01) | |
def scramble(): # Scramble values of the keys and display on screen | |
global key_digit | |
key_digit = shuffle(key_digit, on_key_shuffle) | |
def keys_clear(): # Set display in the Start mode, key LEDs off | |
for i in range(KEY_COUNT): | |
macropad.pixels[i] = BLACK | |
group[i].text = " " | |
macropad.display.root_group = group | |
macropad.display.refresh() | |
def unlock(): # What to do when initially unlocked | |
time.sleep(2) | |
def fill(color, keys = key_place): | |
for i in keys: | |
macropad.pixels[i] = color | |
macropad.pixels.show() | |
def wipe(color, duration = 0.025, keys = key_place): | |
for i in keys: | |
macropad.pixels[i] = color | |
macropad.pixels.show() | |
time.sleep(duration) | |
def blink(color, count = 1, on_dur = 0.2, off_dur = 0.05, keys = key_place): | |
for _ in range(count): | |
fill(color, keys) | |
if tone_enable: | |
macropad.play_tone(880, on_dur) | |
else: | |
time.sleep(on_dur) | |
fill(BLACK, keys) | |
time.sleep(off_dur) | |
def strobe(color, on_dur = 0.2, off_dur = 0.05, keys = key_place): | |
for rgb in color: | |
blink(rgb) | |
def flush_queue(duration = 0.1): | |
flush_time = 0 | |
while len(queue) > 0: | |
call = queue.popleft() | |
call[0](*call[1:]) | |
time.sleep(duration) | |
def random_color(mask = 0xFFFFFF): | |
r = random.randrange(0x100000, 0xFF0000, 0x010000) | |
g = random.randrange(0x001000, 0x00FF00, 0x000100) | |
b = random.randrange(0x000010, 0x0000FF, 0x000001) | |
return ( r | g | b ) & mask | |
# Define a class that holds a tone and a duration. | |
# We can construct a list of such objects to represent a melody. | |
class Note: | |
def __init__(self, freq, hold = 0.1): | |
self.freq = freq | |
self.hold = hold | |
def rest(self): | |
time.sleep(self.hold) | |
def tone(self): | |
macropad.play_tone(self.freq, self.hold) | |
def play(self): | |
if self.freq == 0: | |
self.rest() | |
else: | |
self.tone() | |
class Melody: | |
def __init__(self, notes): | |
self.notes = notes | |
def play(self): | |
for note in self.notes: | |
note.play() | |
# Define a melody to play when the keypad is unlocked. | |
# Example below is the Final fantasy victory melody. | |
ff_victory = Melody([ | |
Note(523.25, 0.133), Note(523.25, 0.133), Note(523.25, 0.133), Note(523.25, 0.400), | |
Note(415.30, 0.400), Note(466.16, 0.400), Note(523.25, 0.133), | |
Note(0, 0.133), Note(466.16, 0.133), Note(523.25, 1.200) | |
]) | |
beep_beep = Melody([ | |
Note(440, 0.1), Note(0, 0.1), Note(440, 0.1), Note(0, 0.1), | |
]) | |
# INITIALIZATION ----------------------- | |
macropad = MacroPad() # Set up MacroPad library and behavior | |
macropad.display.auto_refresh = False | |
macropad.pixels.auto_write = False | |
# Set up displayio group with all the labels | |
group = displayio.Group() | |
for key_index in range(KEY_COUNT): | |
x = key_index % 3 | |
y = key_index // 3 | |
group.append(label.Label(terminalio.FONT, text='', color=0xFFFFFF, | |
anchored_position=((macropad.display.width - 1) * x / 2, | |
macropad.display.height - 1 - | |
(3 - y) * KEY_COUNT), | |
anchor_point=(x / 2, 1.0))) | |
group.append(Rect(0, 0, macropad.display.width, KEY_COUNT, fill=0xFFFFFF)) | |
group.append(label.Label(terminalio.FONT, text='ScramblePad', color=0x000000, | |
anchored_position=(macropad.display.width//2, -2), | |
anchor_point=(0.5, 0.0))) | |
# Initialize in a clear state | |
state = STATE_RESET | |
lock_time = 0 | |
flush_time = 0 | |
warn_lock = False | |
key_id = -1 | |
queue = deque([], 10) | |
# MAIN LOOP ---------------------------- | |
while True: | |
# Toggle tones with the encoder button | |
macropad.encoder_switch_debounced.update() | |
if macropad.encoder_switch_debounced.pressed: | |
tone_enable = not tone_enable | |
print("Tone enable:", tone_enable) | |
if state == STATE_RESET: | |
print("Reset state") | |
macropad.keyboard.release_all() | |
password = "" # Reset password entry | |
flush_queue() | |
keys_clear() | |
scramble() | |
warn_lock = False | |
state = STATE_ENTRY # Reset state | |
elif state == STATE_ULOCK: | |
keys_clear() | |
for id, acct in enumerate(secrets['accounts']): | |
#reversed(sorted(list(secrets.keys())))): | |
#cipher.encrypt(title) | |
#cipher.encrypt(secrets[title]) | |
#te = cipher.encrypt(title) | |
group[3 * id].text = acct.get_description(password) | |
group[3 * id + 1].text = "User" | |
group[3 * id + 2].text = "Pass" | |
group[KEY_INPUT].text = "CtrlAltDel" | |
group[KEY_STATE].text = "KeepAlive" | |
macropad.display.root_group = group | |
macropad.display.refresh() | |
lock_time = time.monotonic() + UNLOCK_DURATION | |
warn_lock = True | |
state = STATE_READY | |
elif state == STATE_READY: | |
if 0 < lock_time < time.monotonic(): | |
print("Locking keypad") | |
wipe(CYAN) | |
wipe(BLACK) | |
blink(WHITE, 3) | |
state = STATE_RESET | |
if warn_lock and 0 < (lock_time - time.monotonic()) < LOCK_WARNING: | |
beep_beep.play() | |
warn_lock = False | |
if 0 < flush_time < time.monotonic(): | |
flush_queue() | |
flush_time = 0 | |
else: | |
event = macropad.keys.events.get() | |
if event and event.pressed: | |
key_id = event.key_number | |
# Verify key_id is within group bounds | |
if key_id == KEY_INPUT: | |
print("Unlocking screen") | |
macropad.pixels[key_id] = CYAN # Turn key white while down | |
macropad.pixels.show() | |
if tone_enable: | |
macropad.play_tone(press_tone, 0.1) | |
queue.append((macropad.keyboard.send, | |
macropad.Keycode.CONTROL, | |
macropad.Keycode.ALT, | |
macropad.Keycode.DELETE)) | |
flush_time = time.monotonic() + 1.0 | |
elif key_id == KEY_STATE: | |
print("Resetting unlock timer") | |
wipe(PURPLE) | |
wipe(BLACK) | |
state = STATE_ULOCK | |
if key_id < KEY_COUNT: | |
macropad.pixels[key_id] = PURPLE # Turn key white while down | |
macropad.pixels.show() | |
if tone_enable: | |
macropad.play_tone(press_tone, 0.1) | |
row_id = key_id // 3 * 3 | |
sel_id = key_id % 3 | |
select = group[row_id].text | |
for acct in secrets['accounts']: | |
if acct.get_description(password) != select: | |
continue | |
# Descrypt account and select credential | |
if sel_id == 0: | |
cred = acct.get_credentials(password) # "<username><tab><password>" | |
elif sel_id == 1: | |
cred = acct.get_username(password) # "<username>" | |
elif sel_id == 2: | |
cred = acct.get_password(password) # "<password>" | |
# Queue writing selected credential to the keyboard | |
queue.append(( | |
macropad.keyboard_layout.write, | |
printable(cred) + "\n", | |
)) | |
# Ready queue: flush after 1 second unless timeout | |
# extended by another keypress. | |
flush_time = time.monotonic() + 1.0 | |
elif state == STATE_ENTRY: | |
# Check for key presses/releases | |
event = macropad.keys.events.get() | |
if not event: | |
continue | |
key_id = event.key_number | |
if event.pressed and key_id < KEY_COUNT: # Key pressed | |
old_color = macropad.pixels[key_id] # Save color of key pressed | |
if key_id == KEY_INPUT: # Start key during entry | |
print("Restart password entry") | |
wipe(random_color()) | |
wipe(BLACK) | |
color = [ | |
random_color(0xFF1717), | |
random_color(0x17FF17), | |
random_color(0x1717FF), | |
] | |
strobe(shuffle(color)) | |
state = STATE_RESET | |
elif key_id == KEY_STATE: # Lower right key during entry | |
print("Reroll keys, retain password entry") | |
#macropad.keyboard.release_all() | |
blink(YELLOW, 2) | |
scramble() | |
else: | |
macropad.pixels[key_id] = WHITE # Turn key white while down | |
macropad.pixels.show() | |
if tone_enable: | |
macropad.play_tone(press_tone, 0.1) | |
# Process input - add the key pressed to the password entry | |
key_index = key_id # The 1-9 keys (index values 0 to 8) | |
if key_id == num_digit: # The "0" position is shifted over, | |
key_index = key_id - 1 # so offset by 1. | |
password = password + str(key_digit[key_index]) | |
digest = hashlib.sha256(password.encode()).hexdigest() | |
print(password, digest) | |
# Display the cumulative password guess | |
group[KEY_INPUT].text = password | |
macropad.display.root_group = group | |
macropad.display.refresh() | |
if digest == skullkey: # Success | |
group[KEY_STATE].text = "OPEN" | |
macropad.display.root_group = group | |
macropad.display.refresh() | |
if tone_enable: | |
ff_victory.play() | |
state = STATE_ULOCK | |
elif len(password) >= PASSWORD_MAXLEN: # Fail | |
group[KEY_STATE].text = "FAIL" | |
macropad.display.root_group = group | |
macropad.display.refresh() | |
blink(RED, 3) | |
state = STATE_RESET | |
# Release any still-pressed keys | |
macropad.keyboard.release(key_id) | |
# Change key color back | |
if key_id != -1: | |
fill(BLACK) | |
key_id = -1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import aesio | |
import adafruit_hashlib as hashlib | |
from binascii import hexlify, unhexlify | |
import os | |
# SHA-256 digest of the user's unlock passcode | |
# | |
# The ASCII-encoded digit string that produces this digest must be entered via | |
# the physical keypad on the Macropad in order to: | |
# - Decrypt the string values in the secrets dictionary | |
# - Unlock the device and generate user/pass HID keystrokes | |
skullkey = "f961362589ef5ef2ebc8db200c48ac18526ee711b07a42bd1e70e86d58c1a63b" | |
secrets = {} | |
def shasum(data): # 64 bytes for SHA256 (4x16 bytes) | |
return hashlib.sha256(data).hexdigest() | |
def md5sum(data): # 32 bytes for MD5 (4x8 bytes) | |
return hashlib.md5(data).hexdigest() | |
def pad(data, size = 16): | |
fill = ( len(data) // size + 1 ) * size - len(data) | |
return data + chr(0) * fill | |
block_size = 16 | |
init_vector = md5sum(bytearray(skullkey.encode()))[:block_size] | |
class Cipher: | |
def __init__(self, key, mode = aesio.MODE_CBC): | |
self.cipher = lambda: aesio.AES(md5sum(key), mode, init_vector) | |
def encrypt(self, data): | |
inp = pad(data, block_size) | |
out = bytearray(len(inp)) | |
self.cipher().encrypt_into(inp, out) | |
return out | |
def decrypt(self, data): | |
out = bytearray(len(data)) | |
self.cipher().decrypt_into(data, out) | |
return out.decode() | |
class UserAccount: | |
def __init__(self, description, credentials): | |
self.desc = description | |
self.cred = credentials | |
def get_description(self, key): | |
return Cipher(key).decrypt(self.desc) | |
def get_credentials(self, key): | |
return Cipher(key).decrypt(self.cred) | |
def get_username(self, key): | |
cred = self.get_credentials(key).split('\t') | |
return cred[0] if len(cred) > 0 else None | |
def get_password(self, key): | |
cred = self.get_credentials(key).split('\t') | |
return cred[1] if len(cred) > 1 else None | |
def print_constructor(self, key): | |
print(f"UserAccount(\n ", | |
str(Cipher(key).encrypt(self.desc)) + ",\n ", | |
str(Cipher(key).encrypt(self.cred)) + ",\n)", | |
) | |
# Secret digests are generated by encrypting the original string value with the | |
# MD5 digest of the user's original unlock passcode that was used to generate | |
# the skullkey digest. | |
# | |
# Thus, changing the skullkey will not yield the original secrets correctly. | |
# A user must know the _original_ unlock passcode to decrypt the secrets. | |
# | |
# If the passcode is changed, the secrets dictionary must also be updated with | |
# the new encrypted digests (use: UserAccount.print_constructor(), see below). | |
# | |
# Neither the original string value nor the user's unlock passcode are stored | |
# anywhere in the source code. | |
secrets['accounts'] = [ | |
UserAccount( | |
b'>\xd0\xa1\x81\xef\xe5\x07s\x9ag\xf9\n6\x84/C', | |
b'\x81\x89\xed\xf0\x87\x97\x9bK\x82\xc8\x7ff\x0b\xc9\xb0\xf1\xf2C\x89\xa1p\x9aSc7\x1f=j\x92\x89\xf7>', | |
), | |
UserAccount( | |
b'\xa5\xd0\xadwK\x14~\x17\xfa\x9aa9\xde\xf2\xf4y', | |
b'\\&\xfd\x88i\xced\xcc\xbc\xfax\x13n\xee\x8d\xa3\xc3\x1a[\xccW\x1d\xe6>b\x95\\Z\xed\xa8gt', | |
), | |
] | |
# Example: create the source code for an element in secrets['accounts'] above: | |
# | |
# UserAccount('FOO', 'myuser\tmypassword').print_constructor('1234678') | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment