
Linux desktop fingerprint login using a Grow R503 sensor + Arduino + a Rust fprintd-replacement daemon
A from-parts USB fingerprint reader for Linux desktops. Total parts cost
under $15. Drop-in replacement for upstream fprintd — PAM, KDE Settings,
GNOME Settings, fprintd-verify, sudo with finger, screen-unlock with
finger all work.
As of fw=1.0 / r503d 1.0.0 the Arduino↔host wire is authenticated:
every command and response carries a SipHash-2-4 MAC keyed to a
TOFU-paired secret in EEPROM. Replay and hot-swap attacks against the USB
serial link are blocked. See SPEC.md §13 for the full design,
including what the threat model doesn't cover.

wish I had a 3d printer…
┌──────────┐ UART ┌─────────────┐ USB-CDC ┌──────────────────┐
│ Grow │ 57600 8N1│ Arduino │ /dev/r503 │ r503d daemon │
│ R503 │◀─────────▶│ (firmware) │◀──────────▶│ net.reactivated │
│ sensor │ 3.3V TTL │ │ framed, │ .Fprint on D-Bus│
└──────────┘ └─────────────┘ MAC'd └──────────────────┘
│
▼
PAM, KDE, GNOME,
fprintd-verify, …
Hardware USB fingerprint readers for Linux are scarce, expensive, and the ones that exist (Validity, Synaptics, etc.) are reverse-engineered through unstable libfprint drivers that break with vendor firmware updates. The Grow R503's protocol is public, the Arduino side is your own code, and the libfprint compatibility layer is just D-Bus.
You also end up with a fingerprint reader you can read the source of, top to bottom.
| Part | Notes | Approx cost |
|---|---|---|
| Grow R503 capacitive fingerprint sensor | The round one with the RGB ring | ~$10 |
| Arduino Uno R3 / Nano / Mega / any ATmega328 board | Anything that runs SoftwareSerial | $5–$25 |
| 4–6 jumper wires | Dupont / breadboard | trivial |
That's it. No level shifter, no voltage divider — see SPEC.md §3.1
for why (the R503's RX line is 5V-tolerant in practice; the datasheet lies).
R503 Arduino (Uno R3 / Nano / etc.)
---- ------------------------------
Red (VCC) 3V3
White (3.3VT) 3V3 (touch-IC supply; shares rail with red)
Black (GND) GND
Yellow (TXD) D2 ── SoftwareSerial RX
Brown (RXD) D3 ── SoftwareSerial TX (direct — no divider!)
Blue (WAKEUP) D4 (optional; not used by firmware yet)
If your R503 ships with the JST-SH connector, snip a 6-pin JST-SH-to-Dupont pigtail to break the wires out. Brown is sometimes green depending on the seller — verify against the wire that goes into the RXD pin of the JST header, not the colour.
Tested on Fedora 44 KDE; should work on any systemd-based distro with
fprintd, pam_fprintd, and a recent Rust toolchain.
System packages:
| Distro | Build | Runtime |
|---|---|---|
| Fedora / RHEL | rust cargo arduino-cli tpm2-tss-devel | fprintd pam fprintd-pam tpm2-tss |
| Debian / Ubuntu | rustc cargo arduino-cli libtss2-dev | fprintd libpam-fprintd libtss2-esys-3.0.2-0 |
The tss-esapi packages are only needed if you plan to use --pair --seal-tpm
(SPEC §13.12). The daemon builds and runs without a TPM otherwise — tss-esapi
is a hard build dep but a soft runtime dep (the code path is only entered when
/var/lib/r503d/key.tpm exists).
Rust 1.95+, arduino-cli on your $PATH.
Do you have a TPM2?
ls /dev/tpmrm0 && tpm2_pcrread sha256:7 | head -3
If both succeed, your host can use the sealed-key path. If /dev/tpmrm0 is
missing (older hardware, TPM disabled in BIOS, or a VM without a virtual TPM),
stick with the default plaintext-key flow.
Open firmware/r503fp/r503fp.ino in the Arduino IDE and upload. Or with
arduino-cli:
# Uno R3:
arduino-cli compile --fqbn arduino:avr:uno firmware/r503fp/
arduino-cli upload --fqbn arduino:avr:uno --port /dev/ttyACM0 firmware/r503fp/
# Nano (modern Optiboot, including most Elegoo / WAVGAT clones):
arduino-cli compile --fqbn arduino:avr:nano:cpu=atmega328 firmware/r503fp/
arduino-cli upload --fqbn arduino:avr:nano:cpu=atmega328 --port /dev/ttyUSB0 firmware/r503fp/
# Nano with legacy 57600-baud bootloader (older clones):
# replace `cpu=atmega328` with `cpu=atmega328old`
The firmware uses Adafruit_Fingerprint. The IDE will offer to install it
on first compile.
If arduino-cli upload fails with not in sync: resp=0x7e, your bootloader
is the other variant — swap atmega328 ↔ atmega328old and retry. Both
work; the difference is just bootloader baud rate.
Requires Rust 1.95+.
cd pcside/daemon
cargo build --release
sudo bash pcside/daemon/dist/install.sh
That script:
target/release/r503d to /usr/local/bin/r503d/var/lib/r503d/ (mode 0700 root:root) for the key, state, and
user-slot registry/dev/r503 and locks the
device node to root:root 0600 (only the daemon, running as root, needs it;
this closes the default 0660 root:dialout path so no other local user can
open the port — security audit 2026-05-28 / H1). Consequence: after
install, any manual arduino-cli/serial-monitor command against /dev/r503
needs sudo./etc/systemd/system/r503d.service)net.reactivated.Fprint/usr/share/polkit-1/actions/net.reactivated.fprint.device.r503d.policy)
used by the caller-identity gate/etc/dbus-1/system.d/net.reactivated.Fprint.conf) — only root and
wheel members can talk to the daemon; everyone else hits
AccessDenied at the broker, before the daemon sees the callfprintd.servicer503d.serviceIt's idempotent — re-run it after every cargo build --release to
redeploy the new binary.
A freshly-flashed Nano is unpaired — the daemon would talk to it but the firmware would reject every framed command. Pick one of the two flows below; both end with a paired Nano and a working daemon. The TPM-sealed flow is recommended if your host has a TPM2 (see Prerequisites for the quick check).