dumbtv
dumbtv takes the ads, screen tracking and update nags out of your Fire TV over ADB. No root, none of your apps touched, every step can be undone. Free and open source.
A small key that plugs into USB. Touch it to unlock, approve and authenticate — never a password.
SecurityC · PythonGitHub @openhankoOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Firmware for OpenHanko, fingerprint unlock for any Mac. RP2350, PIV smart card over USB CCID, signing key generated on the device.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Firmware for OpenHanko: a USB device that authenticates
macOS login, sudo and authorisation prompts. It enumerates as a PIV smart card
over USB CCID and signs with a P-256 key it generated itself, only while someone
is touching it.
Derived from ZimengXiong/tinyTouch
(firmware/tiny_touch_smartcard); the PIV applet and CCID transport are
substantially upstream's work. MIT, with that copyright retained — see
LICENSE and compared with tinyTouch.Read the full README ↗Firmware for OpenHanko: a USB device that authenticates
macOS login, sudo and authorisation prompts. It enumerates as a PIV smart card
over USB CCID and signs with a P-256 key it generated itself, only while someone
is touching it.
$ system_profiler SPSmartCardsDataType
Readers:
#01: OpenHanko Smart Card (ATR:{length = 4, bytes = 0x3b800101})
Derived from ZimengXiong/tinyTouch
(firmware/tiny_touch_smartcard); the PIV applet and CCID transport are
substantially upstream's work. MIT, with that copyright retained — see
LICENSE and compared with tinyTouch.
Working end to end on hardware. macOS enumerates the device, attaches a PIV
token driver, reads the certificate, pairs it to the account, and authenticates
sudo and the login window. The device generates its own key at first boot,
names itself after that key, and can erase itself back to factory state without
a host.
The sensor, the enrolment gesture, module binding, encryption at rest, secure boot and debug lockout are all in force. What is left is written up as gaps rather than features, in THREAT-MODEL.md:
CONFIG_UNLOCK opens on a button press rather than a fingerprint. What it
gates is BOOTLOADER and nothing else — see
the button does not authenticate.RP2354A, A4 stepping, with an HLK-ZW111 fingerprint module.
A4 is a requirement rather than a preference: errata E16, E20 and E24 are fixed
in silicon and in no other way, and on A2 an attacker with the device and
glitching equipment recovers the signing key whatever the firmware does. It
cannot be told from CHIP_ID.REVISION, which reports the same value as A3 — the
boot ROM version byte at 0x00000013 is the one that distinguishes them, and
STATUS reports the result as chip=.
Built for PICO_BOARD=openhanko, defined in
src/boards/openhanko.h. No stock SDK board describes
this part: the nearest is pico2, which assumes 4 MB of flash in a separate
chip, and storage.c and settings.c derive their regions from
PICO_FLASH_SIZE_BYTES — so building for the wrong board places the identity and
the settings outside the flash that exists.
Pins are in src/board_config.h.
| VID | 0x16D0 — MCS Electronics |
| PID | 0x1551 |
| serial | openhanko.io:<12 hex>, from the board's unique id at runtime |
| strings | manufacturer OpenHanko, product Smart Card |
The manufacturer and product strings must differ: macOS builds the reader name by
concatenating them, so setting both to OpenHanko makes the pairing dialog read
"OpenHanko OpenHanko".
These are set in src/board_config.h.
export PICO_SDK_PATH=~/.pico-sdk/sdk/2.2.0
cd src && cmake -S . -B build && cmake --build build
Hold BOOTSEL while replugging, then copy the image onto the volume that appears:
cp -X build/openhanko.uf2 /Volumes/RP2350/
Double-tap RESET enters the bootloader without holding BOOTSEL — but only on
a board where BOOT_FLAGS1.DOUBLE_TAP has been burned, which bootkeys.py does
during stage 1. On a board with nothing burned, use the BOOTSEL button or short
QSPI_SS to ground through about 1 kΩ. See recovery.
PICO_SDK_PATH must be set explicitly on a fresh clone; an existing build/
directory caches it.
The version is project(… VERSION …) in src/CMakeLists.txt plus the commit,
stamped at build time: 0.2.0+09f45d2. A build from uncommitted source says
.dirty, which a signed release image should never do. STATUS reports it as
fw=, and picotool info reads it from a board or a .uf2.
A fingerprint match is the only thing that authorises a signature. The button's
jobs are all configuration: the factory reset gesture, opening enrolment, and
unlocking BOOTLOADER. No build flag changes this and no console command signs,
so a unit with no sensor fitted cannot authenticate at all — which is the correct
failure.
CONFIG_UNLOCK does ask for a press rather than a match. What it gates is
BOOTLOADER, and a locked unit still accepts only signed firmware, so the press
buys an update path rather than a key.
Adding a finger is the one operation that keeps the key and adds a way to use it, so it is the one that has to be authorised. Wiping cannot be abused — it destroys the key — but adding a finger to a working device would hand someone a credential that answers to them.
Rest an already-enrolled finger on the sensor and click the button. The click marks intent; the finger authorises it. Then lift, and present the new finger.
| ring | |
|---|---|
| click, finger matches | two green flashes, then breathing purple |
| click, finger does not match | steady red, held, and nothing opens |
| click, no finger | nothing — the click alone does not authenticate |
| waiting for the new finger, 30 s | breathing purple |
| enrolled | steady green |
| timed out or failed | steady red, nothing stored |
Flashing against steady carries the accept/reject distinction, with colour only reinforcing it: green against red is the pair red-green colourblindness collapses, and at the gate this is the only feedback there is.
The first finger is a special case, because there is nothing yet to match against. A device with an identity but no template cannot authenticate for anybody, so it opens enrolment by itself at boot and keeps offering until one takes. That also removes the window the gesture cannot cover: there is never a period in which the device is paired and useful but unenrolled, which is the only period in which appropriating it would be worth anything.
One finger is enough to get going. A device asks for one at first boot and stops asking once it has it. If the enrolled finger ever becomes unavailable, factory reset is the way back: hold the button through power-up, enrol again, pair again — a minute, and a complete recovery for unlocking a Mac.
That recovery destroys the key, though, so it is only cheap if nothing expensive was bound to it. An SSH identity trusted by a fleet of hosts, or a CA-issued certificate, has to be re-provisioned everywhere. Enrol a second finger through the gate if the device is used that way.
A device with no identity generates a P-256 keypair and self-signed certificate at first boot. The private key is never transmitted and never copied to a host — though it is not unextractable: on every state shipped so far it can be read out of flash by someone holding the device. See THREAT-MODEL.md.
Certificates are named after the tail of the public key's SHA-1, which is also
how macOS derives kSecAttrApplicationLabel — the hash sc_auth pairs against:
$ STATUS
… source=flash alg=p256 keyrc=-0x0000 name="OpenHanko #FA764A"
$ sc_auth identities
CD01D8E61DB4E82E005F560781284FCE79FA764A vden - Certificate For PIV Authentication (OpenHanko #FA764A)
^^^^^^
So a device states its own name over the console, and that name identifies its keychain entry. Two devices on one Mac are told apart without unplugging either.
get_rand_64() is backed by the RP2350's hardware TRNG, which is the reason a
key generated here is worth trusting and one generated on a part without a true
entropy source is not.
Nothing can put a key on the device from outside: no console command, and no
build that bakes PEMs into the image. STATUS reports source=flash or
source=none and there is no third answer, so "the private key never existed
off this chip" describes the device rather than how it was set up.
./provision.py is stdlib-only — no pip install, no venv — and only inspects
and pairs.
Hold the button through power-up. There is no console or USB command for this and deliberately so — erasing credentials is the one operation a host should not be able to start. It destroys the key and the enrolled templates, restores every setting to its default, and reboots, generating a fresh identity on the way up.
The indicator blinks, faster as the deadline approaches, and goes solid at six seconds; releasing is what commits. Let go early, or keep holding, and nothing happens — once solid, unplugging while still holding is the way out.
Release is the commit deliberately. A device wedged against something in a bag can hold a button indefinitely but cannot let go.
./provision.py ports # find the device
./provision.py status # what does it know?
./provision.py pair # link it to your macOS account
sudo -k && sudo -v # test: touch the sensor when macOS asks for the PIN
macOS usually offers to pair on its own when the card is inserted; the "Unpaired
SmartCard inserted" notification does the same thing as pair. pair reads the
identity hash out of sc_auth identities and prints the sudo sc_auth pair
command to run, or runs it with --run. The app in
openhanko-macos does the same
thing with a button.
./provision.py monitor prints device events live, which is the fastest way to
tell whether a touch registered.
CDC console, 115200. ./provision.py console '<CMD>' sends one.
| command | effect |
|---|---|
PING | → PONG |
STATUS | silicon stepping, firmware version, key source, algorithm, AID mode, sensor, lockdown state, name |
TRACE / TRACE_CLEAR | ring buffer of CCID and APDU activity, and whether a finger was accepted or refused |
FINGERPRINT_PROBE | re-run the link probe and report what answered |
FINGERPRINT_INFO | model, firmware, manufacturer, sensor name |
FINGERPRINT_SN | the module's per-die serial — what binding is against |
FINGERPRINT_SECPROBE | whether this module implements the safety instruction set, and what it returns |
FINGERPRINT_INFO_RAW | the raw 512-byte info page as hex, for checking the field offsets |
OTP_STATUS | whether the device holds a secret, and which one, by hash |
IDLE_LIGHT [colour] | what the ring shows when idle; off for nothing. Persists |
AID_MODE standard|pinpad | force the AID mode instead of letting the probe decide |
BOOTLOADER | reboot to USB mass-storage bootloader |
REBOOT, USB_RECONNECT | as named |
CONFIG_UNLOCK | required before BOOTLOADER |
Nothing here is irreversible, and nothing here reaches a key. No command loads one, generates one, signs with one, enrols a finger, erases a template or burns a fuse — those either moved to a physical gesture or stopped existing.
Two module bring-up commands, FINGERPRINT_REG and FINGERPRINT_GETKEY, are
compiled out unless -DFINGERPRINT_LAB_TOOLS=ON is passed. They write the
module's own registers and ask it for a key pair, which the manual says clears
every enrolled template — irreversible, and the reason they are not in a shipped
image.
Two commands write, both to the settings sector and neither to anything else.
AID_MODE costs a button press of its own, because it decides which driver
macOS binds and a host that could change that unobserved would be choosing how
the device is spoken to. IDLE_LIGHT costs only the configuration window,
because it gates nothing and reveals nothing — a host that can already open that
window gains nothing by changing an LED.
That matters because the console is on the same cable as the card: whatever it can do, a host that owns the Mac can do.
One image serves both a Mac with our CryptoTokenKit driver and a Mac without one. The device switches on its own, in both directions, and persists the choice across reboots.
standard mode answers the PIV AID + the private AID as a probe
(factory default) │
Apple's pivtoken binds │ our driver SELECTs the private AID —
a touch types the PIN │ nothing else knows to ask for it
▼
persist pinpad, reboot
│
pinpad mode ▼
answers the private our driver binds exclusively;
AID only a touch alone signs, LED breathing
│
│ no private-AID select within 10 s
│ of the host first speaking
▼
persist standard, reboot
The probe is the mechanism: only our driver knows to ask for the private AID, so a SELECT of it proves the driver is installed. The device is never told.
Answering both AIDs is safe only because the state is transient. macOS binds exactly one token driver per card, at insertion, so a card that stays in that state leaves two drivers racing — the winner flips between reboots, and the loser can have its identity filed under "not used for authentication" while the card silently stops authenticating. Here the state lasts a few hundred milliseconds before the reboot makes it exclusive.
Consequence: with the driver installed, forcing standard mode does not stick — the device meets the driver again and upgrades straight back. Driverless behaviour on a Mac that has the driver means uninstalling it.
The driver selects the private AID, attempts a signature and is refused with
6982; that refusal is what makes CryptoTokenKit call beginAuth, which sends
PC_to_RDR_Secure. The card holds the request open until a finger matches, then
signs.
There is no ins=20 VERIFY anywhere in it: no PIN is typed or transmitted. The
CCID 69 is the secure-PIN request, and only our driver sends one.
While that request is outstanding the reader is claimed, so the card bounds it:
a CCID time extension goes out once a second, and after 20 s the request is
refused and released. In practice the host gives up first — CryptoTokenKit
abandons the operation after about ten seconds, which is when the ring stops
flashing and, on sudo, when pam_smartcard falls back to asking for a PIN on
the TTY.
The order inverts. Nothing asks the card anything until a PIN has been
submitted, so the match comes first, the device types six digits, pivtoken
sends them as VERIFY, and the signature follows. macOS labels the prompt
"Certificate For PIV Authentication (…)", which is pivtoken's own format.
The PIN is six random digits, generated fresh for each prompt, and is not a
secret: VERIFY discards the bytes and accepts anything. The presence check is
the only gate.
Random rather than fixed, because a fixed one teaches a number that looks like it means something and does not. The corollary is worth knowing: if you ever face a PIN box the device did not fill in, any six digits will do.
The fingerprint module's own ring, driven over the same UART that carries matches. There is no separate LED, which means the light the user is asked to react to is on the surface they are asked to touch.
PS_ControlBLN takes a three-bit colour mask, one bit per channel and no
intensity, so the palette is exactly eight values and nothing between them:
| mask | mask | ||
|---|---|---|---|
0x00 | off | 0x04 | red — refused, or armed to erase |
0x01 | blue — idle (steady), waiting (flashing) | 0x05 | purple — enrolling |
0x02 | green — accepted | 0x06 | yellow — powered up |
0x03 | cyan — unused | 0x07 | white — unused |
Anything off that list, orange included, is not a colour this ring can be asked for. Only the breathing effect takes a start and end colour separately, and it transitions between two of the eight rather than mixing them.
There is no brightness control either — PS_ControlBLN carries a function
code, two colours and a cycle count, and that is the whole command. The only
dimmers are which channels are lit, and what fraction of the time.
Idle and waiting share blue and separate by effect, steady against flashing,
rather than by colour — so the cue survives the idle colour being changed to
something else, which IDLE_LIGHT allows. Waiting flashes rather than breathes
because it is the only state that has to interrupt somebody, and an abrupt edge
is what peripheral vision catches. Breathing is left to enrolment, where the
device waits rather than asks.
| mode | behaviour |
|---|---|
| power-up | two yellow flashes, then the ring goes to whatever the state calls for |
| idle | steady blue by default — the dimmest the ring goes without pulsing, and enough to find on a dark desk. Set with IDLE_LIGHT, including off |
| pinpad | flashes blue while waiting — macOS shows no prompt, so this is the entire invitation, and it has to interrupt whatever the user is looking at |
| standard | solid flash, 700 ms, on a match, held through the signature |
| finger refused | steady red, held, whether the refusal came from the enrolment gate or from an ordinary touch |
The asymmetry between the modes is inherent: macOS says nothing to the card until a PIN has already been submitted, so in standard mode there is no event to light up on and the device can only acknowledge a match after the fact. A sensor that reads a finger without a flicker looks broken, especially when the PIN it typed lands in a window the user is not looking at.
The power-up flash is there because a device that boots, binds and sits dark is indistinguishable from a dead one. It is skipped when no finger is enrolled, where the purple that means "enrol one" is more useful, and when the module does not match, where red means stop.
| signing | measured |
|---|---|
| P-256 on RP2350 | 196 ms |
| P-256 on RP2040, for comparison | 469 ms |
| RSA-2048 on RP2040 | 2924 ms |
| end to end | |
|---|---|
| touch to authenticated, pinpad | ~210 ms |
| APDU transfers for the challenge | 1 (2 chained for RSA) |
RSA is slow because there is no big-integer accelerator. Signing also runs synchronously inside the CCID transfer callback, so USB stalls for the whole operation — tolerable at 200 ms, not at three seconds.
macOS picks the algorithm from the certificate with no configuration: a P-256
cert gets GENERAL AUTHENTICATE with P1 = 0x11 instead of 0x07.
MBEDTLS_ECDSA_DETERMINISTIC, and not optional. A predictable ECDSA nonce
recovers the private key from a single signature; deriving the nonce from the key
and the message takes the RNG out of the signing path entirely, which removes the
whole class of failure rather than mitigating it.
MBEDTLS_PK_PARSE_EC_EXTENDED is deliberately not enabled, so mbedTLS rejects
explicit EC parameters — field type, prime, generator, the lot — with
MBEDTLS_ERR_ECP_FEATURE_UNAVAILABLE (-0x4E80). mbedtls_pk_write_key_pem()
in identity.c writes named-curve, which is what real PIV cards carry and is
135 bytes against 377.
Worth knowing because the failure mode is confusing: the certificate decodes,
the card enumerates, macOS reads the identity, and only signing fails. STATUS
reports alg= and keyrc=.
MBEDTLS_PEM_PARSE_C fails the same wayCertificates still work, because decode_pem_cert() in piv.c does its own
base64. The card enumerates, macOS offers to pair, and only signing fails, with
6f00 — where a missing presence check would have given 6982.
pivtoken ignores pinpad entirelyCCID readers can declare bPINSupport and collect the PIN themselves, which
would mean no text field, no keystrokes, and nothing to focus. CryptoTokenKit
does read that byte out of the CCID descriptor.
With Apple's built-in pivtoken: not a single PC_to_RDR_Secure, even with
bPINSupport = 0x01 declared.
With the driver in openhanko-macos it works — no dialog, nothing typed, authenticated by the fingerprint alone.
sudo is seamless, including the prompt: that repository's tools/pam module
runs ahead of pam_smartcard.so and goes through CryptoTokenKit, so the token
driver performs the authentication and the touch is the whole interaction. A
sudo traced on this device shows CCID 69 Secure, the match, and the
signature, with no VERIFY at all — nothing was typed.
What is not solved is applications that put up their own PIN field. Chrome's password manager unlocks correctly, but still shows a modal and takes the six digits the device types. Pinpad governs the CryptoTokenKit-to-card leg only; an application that collects a PIN itself never reaches it.
A new HID keyboard opens Keyboard Setup Assistant on first insertion, asking the device to press keys beside Shift that it does not have. The dialog looks like a fault and can need a replug to get past.
loginwindow's own trace shows what it decides on: it reads the interface's
top-level usage, looks up the vendor/product/country triple, and launches the
assistant when the triple is unknown. bCountryCode only changes that key — set
to 33, a never-seen device got the dialog on cue — so the lever is the usage.
The HID interface therefore declares Keypad (7), not Keyboard (6), with no
boot-keyboard subclass or protocol. It types ten digits and Enter, so that is
also the honest description. Every array item stays on the same usage page and
the six-byte report is unchanged.
Smart-card token keys are only visible from a process with a full user session.
Automated shells — CI, agents, some launchd contexts — query the keychain, see
nothing, and conclude the token is broken. sc_auth identities and the device's
own TRACE are the reliable witnesses; security list-smartcards lags well
behind reality.
BLE was built and measured before it was dropped — the device served the same
applet over the air and sudo authenticated wirelessly. Four reasons it is not
shipped:
./bootkeys.py primary.pem spare.pem build-rp2350/openhanko.uf2 out/
./provision-board.py out/ # rehearse, touching nothing
./provision-board.py out/ --commit
That takes a blank board to a finished one: signed firmware, both boot keys, the bootrom's recovery, a device secret, key material encrypted at rest, secure boot, and no debug port. Every OTP write is permanent, so the script verifies what landed before continuing and refuses on any disagreement.
The order is not arbitrary. The device burns its own secret the first time it generates an identity, so it has to boot and run before page 4 is locked — lock first and it can never write one, and a device with no secret cannot store an identity at all, which looks like a working board right up until you try to pair it. Secure boot comes after that firmware is proven to boot on that board. Debug goes last, because it is the step that removes the ability to diagnose the others.
A run that stops partway leaves a real board in a real state, so the script resumes by reading OTP rather than by being told where it got to.
./provision-board.py out/ --unlocked --commit
Stops once the device has made its own secret: no boot keys, no secure boot, no
debug lockout. The board is complete and works — it simply runs whatever
firmware it is given, and STATUS reports secureboot=off debug=open so it says
so.
No slots are invalidated either, and that is the point of a separate config.
stage1-keys.json burns our two keys and nails slots 2 and 3 shut, which the
datasheet asks for and which leaves an owner no slot of their own;
stage1-taponly.json burns the double-tap recovery and nothing else, so all four
stay open. Somebody who wants to sign their own firmware can, and somebody who
wants ours can burn our public fingerprints instead.
The same resumability finishes one later: re-run without --unlocked and it
burns the keys, enables secure boot and disables debug, ending exactly where a
factory-locked unit ends.
What it gives up is the whole of encryption at rest. The secret is burned and the flash is ciphertext, but with secure boot off anyone holding the board can run firmware that reads the secret back — so the wrapping protects nothing. That is row 2 of the threat model, not row 4.
Four boot key slots exist. Two are used and the other two are marked invalid, which the datasheet asks for: otherwise somebody who later gets a write to OTP can install a key of their own and sign what they like.
The second key is a recovery path, not a copy. If the primary leaks, revoke it and sign with the spare — a primary-signed image is then rejected and a spare-signed one still boots, so the fleet survives. With one key, a leak or a loss is terminal for every unit ever burned.
Keep the spare offline. It is worth nothing if it lives beside the primary.
BOOT_FLAGS1.DOUBLE_TAP is burned during stage 1, before secure boot, and it
matters that it is the bootrom's mechanism rather than the SDK's. The SDK's ran
in the application; enabling secure boot stops it working, and it needed the
firmware to boot — which is not when a way back in is wanted. The bootrom's runs
before any application, so it rescues a board whose firmware is broken, unsigned
or missing.
BOOTSEL and picotool keep working after lockdown. That is deliberate: a unit stays updatable with signed firmware forever, while revealing nothing.
The PIV keys in flash are AES-256-GCM ciphertext under a key derived from a 32-byte secret the device generates for itself and burns into OTP. Reading the flash yields nothing usable.
GCM rather than a bare cipher because the tag is what makes a bad decrypt legible. Without it, a corrupted or tampered record decrypts to plausible bytes that mbedTLS then tries to parse as a PEM key, and the complaint arrives about the wrong thing entirely.
The secret is stored chaffed — every bit beside its own complement. That is aimed at IOActive's passive voltage contrast attack, which reads antifuse cells directly rather than through any access control and recovers the bitwise OR of adjacent bits. It is the one Hacking Challenge finding A4 does not fix, and complementary pairs are Raspberry Pi's published mitigation: an all-zero secret and an all-ones secret produce byte-identical readouts.
Once the page is locked and debug disabled, that secret is readable by signed
firmware on that die and by nothing else — not SWD, not the bootloader, not
picotool otp get.
STATUS reports secureboot= and debug= for exactly this reason. A unit with
neither fuse burned holds the same ciphertext in flash and the same secret in
OTP, and until it could say so it was indistinguishable from a locked one — to
its owner, and to the app that told them their key material was safe. ? means
the two copies of CRIT1 disagree, which is a fault rather than an answer.
Which makes the firmware the oracle. It can read the secret; that is the
design. A bug that leaks it costs everything the rest of this bought, which is
why OTP_STATUS reports six bytes of a hash and never the value, and why
nothing should ever be added that returns it.
Worth stating plainly, because it is the limit that no firmware here closes.
The ZW111 speaks the EF-01 / ZhianTec-family protocol over plain UART at 57600. Confirmed opcodes, cross-checked against GavinnnTann/HLK-ZW-Fingerprint-Sensor and what this driver implements:
| opcode | command | implemented here |
|---|---|---|
0x01 | PS_GetImage | yes |
0x02 | PS_GenChar — image to buffer | yes |
0x05 | PS_RegModel | yes |
0x06 | PS_StoreChar | yes |
0x0d | PS_Empty — erase all templates | yes |
0x04 | PS_Search | yes |
0x0f | PS_ReadSysPara | no |
0x12 | PS_SetPwd — persists to flash | no |
0x13 | PS_VfyPwd | yes |
0x14 | PS_GetRandomCode — module's own hardware RNG | yes |
0x16 | PS_ReadINFpage — parameter page; its Product SN is a model string | yes |
0x34 | PS_GetChipSN — 32-byte per-die serial | yes |
0x0e | PS_WriteReg — register 7 is the encryption level | no |
0xe0–0xe4 | safety instruction set: key pair, lock, ciphertext, secure store/search | no |
0x1d | PS_TemplateNum | yes |
0x3c | AURALEDCONFIG — ZW1xx only | yes |
As shipped, at encryption level 0, there is no authenticated mode. The 4-byte password is not merely sent in clear on every power-up — it is readable from the parameter page, so an attacker does not even need to be listening at the right moment. A forged match response makes the device sign, and secure boot, SWD lockout and encryption at rest all keep working correctly because none of them are in that path.
The manual describes a way out that these modules do not have. Hi-Link
documents a safety instruction set (0xE0–0xE4) gated behind an encryption
level in register 7. At levels 2 and above the module stops answering the
ordinary commands — no PS_Search, no PS_StoreChar, no automatic registration
— and enrolment and verification move into the safety set, where a verification
is a challenge-response: the MCU sends E_A(R) for a fresh 16-byte nonce, and
the module returns E_B(T ‖ I ‖ S ‖ R[0..10]), one block carrying the result and
eleven bytes of that nonce. Forging it needs key B. PS_LockKeyt then refuses to
ever issue another key pair, so provisioning at assembly and locking leaves an
attacker unable to ask the module for one.
ECB does not sink it, though it looks as though it should: P is exactly one
block, so there is no block structure to leak, and the
response carries 88 bits of a nonce the attacker cannot predict.
None of it is reachable on these modules. PS_GetCiphertext answers exactly
as an opcode that does not exist does; writing register 7 is accepted but leaves
Secur Level reading 0, so the encryption level never changes; and PS_GetKeyt
returns success without clearing enrolled templates, which is its first
documented act and the one it cannot skip. Three independent readings, one
conclusion. That is consistent with a manual which scopes the set to "some
fingerprint module products based on security chips" and never claims the ZW111
is one, and it says nothing about the part being genuine.
FINGERPRINT_SECPROBE is the check, and stays for the day a module from a line
that does carry the security chip turns up. It reports the raw confirmation code:
0x31 or 0x2e mean the opcode is implemented, 0x00 means it is not.
Until such a module exists, the honest claim is unchanged: resists a compromised host, resists offline key extraction, does not resist physical possession. A PIN mixed into the key-wrapping KDF closes the same attack without depending on the sensor at all — see THREAT-MODEL.md.
What raises cost without pretending to be authentication:
Bind to the module. PS_GetChipSN (0x34) returns a 32-byte serial the
manual describes as unique to the die. Store a hash at pairing and refuse a
module that changes. Defeats a swap with a stock module, not an emulator that
replays the expected serial. Implemented and in force: the device records
the module it finds when it has none stored, and refuses everything if it later
meets a different one. Confirmed across modules: several of the twelve
meaningful bytes differ between them, so the serial is genuinely per-die.
Not the info page's Product SN, which the manual defines as "indicate product model" — it names the part, not the unit, and binding to it would detect only a different kind of sensor, and every module reports the same value.
Correlate TouchOut. Implemented. A match is discarded unless the line
says a finger is present, checked before the capture and again after it.
Drive the full sequence. Never trust one confirmation byte; run
GetImage → GenChar → Search and check each stage against the last.
Bound the timing. Reject responses that arrive implausibly fast. Bounds have to be measured across real modules, not guessed — capture latency varies with finger quality.
Note what is not on that list: deriving a key from a fingerprint template. The ZW111 runs a self-learning algorithm, so templates mutate with use and no derived key would be stable, and the template is readable by whoever holds the module, so it is not secret either.
The full per-use-case analysis is in THREAT-MODEL.md; the short version:
VERIFY accepts any PIN and opens a window. macOS
insists on collecting one; the fingerprint is the only real gate.src/ device firmware, RP2350 family
boards/openhanko.h the board definition; no stock SDK board fits this part
board_config.h pins, AID default, timings, USB identity
main.c cooperative loop: presence, indicator, enrolment gate,
mode switching
piv.c PIV applet: certificates, VERIFY, GENERAL AUTHENTICATE,
ECDH on slot 9D
identity.c generates the device's own keypair and certificate
fingerprint.c HLK-ZW111 over UART (EF-01), PS_AutoEnroll, module binding
button.c the one input; factory-reset gesture and enrolment intent
status_led.c indicator mux, on the module's ring
settings.c AID mode and idle colour, in their own flash sector
storage.c the PIV identity, in flash, encrypted, outside the image
otp.c the device secret that encrypts it, in one-time memory
usb_ccid.c CCID class driver over TinyUSB
usb_hid.c HID keyboard, for typing the PIN in standard mode
usb_descriptors.c descriptors, including bPINSupport
config_console.c diagnostics and settings on CDC
trace.c ring buffer of CCID and APDU activity
version.c the version, for STATUS, picotool info and the macOS app
version.cmake stamps release number + commit at build time
mbedtls_config.h which mbedTLS features are compiled in; two of the
implementation notes above are about entries here
provision.py console client: status, events, macOS pairing
bootkeys.py signs an image and prepares the OTP writes
provision-board.py takes a blank board to a provisioned one
The macOS driver and the site are in openhanko-macos and openhanko-web.
This project exists because tinyTouch did the hard part first: a working PIV
applet and CCID transport on a microcontroller, with command chaining, GET RESPONSE chunking and a correct class descriptor. That layer is still
substantially upstream's, and the differences below are what happened when the
same idea was pointed at a different problem — a device you hand to somebody
else, rather than one you build for yourself.
Measured against firmware/tiny_touch_smartcard as of August 2026.
| tinyTouch | OpenHanko | |
|---|---|---|
| MCU | ESP32-S3 | RP2354A — RP2350 with in-package flash |
| signing algorithms | RSA-2048 only — the sign path returns 6f00 for any key that is not RSA, and GENERAL AUTHENTICATE rejects any P1 but 0x07 | P-256 (0x11) and RSA-2048 (0x07), checked against the key actually loaded |
| signature time | — | 196 ms P-256 on an RP2350 |
| ECDSA nonce | n/a | RFC 6979 deterministic |
| where the key comes from | provisioned from a host, or compiled into the image | generated on the device at first boot; both host paths kept as alternatives |
| does the private key exist off-device | yes — on whatever machine generated it | no, on the generate-on-device path |
| slot 9D key agreement | not implemented | ECDH P-256, dynamic auth tag 0x85 |
| pinpad PIN entry | not implemented | bPINSupport declared and PC_to_RDR_Secure answered from a fingerprint |
| AIDs answered | standard PIV only | standard PIV and a private AID |
| driver detection | none | private-AID probe: the device works out for itself whether its driver is installed, and switches mode both ways |
| host software | Python LaunchAgent, AES/HMAC over HID | signed and notarised CryptoTokenKit extension |
| factory reset without a host | no | button held through power-up, release commits |
| on-device diagnostics | STATUS | STATUS, plus a TRACE ring buffer of CCID and APDU activity |
| fingerprint sensor | ZW101 | ZW111, bound to the device by its per-die serial |
| key material at rest | plaintext in NVS | AES-256-GCM under a secret in OTP |
| secure boot | none | signed images, two keys with revocation |
touch_pin_hid.c is 248 lines of AES/HMAC keyed
to the host helper, so the device can type an actual password rather than a
fixed dummy PIN. Dropped here entirely — the CryptoTokenKit route made it
unnecessary for everything except sudo.device_config.c lets one device be a PIV card or an
HID password typer, selectable at runtime. This is PIV only.Key custody. Upstream's key is made on a host and pushed to the device, so whoever provisioned it could have kept a copy. That is fine when you provision your own. It stops being fine the moment somebody else assembles the device, and no amount of assurance fixes it — the question simply should not be askable. Generating on the device removes it: the private key has no representation outside the chip at any point.
Driverless and pinpad from one image. Upstream answers the standard PIV AID,
so Apple's pivtoken binds and the device types a PIN over HID into whatever has
focus. That works with nothing installed, which is genuinely the right default.
But it caps the experience there: HID typing cannot be better than the focused
window, and macOS frequently does not focus its own authorization dialog. Adding
a private AID as a probe gets both — untouched Mac, pivtoken binds, PIN typed;
driver present, our extension binds and a finger alone signs with no dialog at
all. Neither mode needs the user to choose.
P-256 rather than RSA. The ESP32-S3 has a big-integer accelerator, so upstream never paid for RSA-2048. Without one it is brutal: the same signature costs 2924 ms on an RP2040, long enough that the device reads as broken, against 469 ms for P-256 on that part and 196 ms on the RP2350 this ships on. The security consequence came after: a weak RNG is survivable for RSA blinding and fatal for an ECDSA nonce, which is why signing here is RFC 6979 deterministic and why the RP2350's TRNG is the reason to move parts at all.
None of this is a criticism of upstream, which is explicit about being a proof of concept and is a good one.