Skip to content

Instantly share code, notes, and snippets.

@ardnew
Last active May 3, 2025 00:24
Show Gist options
  • Save ardnew/1ddfadacbe8252820c7905053f1a631e to your computer and use it in GitHub Desktop.
Save ardnew/1ddfadacbe8252820c7905053f1a631e to your computer and use it in GitHub Desktop.
Adafruit Macropad - HID Keyboard - Unlock screen with password(s) on Windows Domain
"""
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
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