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 -3If 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 --releasesudo bash pcside/daemon/dist/install.shThat script:
- installs
target/release/r503dto/usr/local/bin/r503d - creates
/var/lib/r503d/(mode 0700 root:root) for the key, state, and user-slot registry - writes the udev rule that exposes the Arduino as
/dev/r503and locks the device node toroot:root 0600(only the daemon, running as root, needs it; this closes the default0660 root:dialoutpath so no other local user can open the port — security audit 2026-05-28 / H1). Consequence: after install, any manualarduino-cli/serial-monitor command against/dev/r503needssudo. - installs the systemd unit (
/etc/systemd/system/r503d.service) - overrides the D-Bus autolaunch entry for
net.reactivated.Fprint - installs the polkit action
(
/usr/share/polkit-1/actions/net.reactivated.fprint.device.r503d.policy) used by the caller-identity gate - installs the restrictive system bus policy
(
/etc/dbus-1/system.d/net.reactivated.Fprint.conf) — onlyrootandwheelmembers can talk to the daemon; everyone else hitsAccessDeniedat the broker, before the daemon sees the call - stops and masks upstream
fprintd.service - starts
r503d.service
It'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).
The opt-in file (/etc/r503d/allow-pair) used in both flows exists to
defeat an attacker racing to your desk with their own Nano — pairing
without root is impossible. r503d --pair deletes the marker before
sending the key to the Nano: if the host crashes between Nano-side commit
and host-side persistence, the gate is already closed, so the next pair
attempt requires admin to touch the marker again. A pre-send bail
(no marker, or "already paired") leaves the marker intact for retry.
Use this if you don't have a TPM2 device, or if you don't need offline-disk attack resistance.
sudo systemctl stop r503d
sudo mkdir -p /etc/r503d
sudo touch /etc/r503d/allow-pair # opt-in (see SPEC §13.5)
sudo r503d --pair # 128-bit key → /var/lib/r503d/key
sudo systemctl start r503dsudo r503d --status
# port: /dev/r503
# firmware: fw=1.1 fmt=2
# firmware paired: true
# firmware counter: 42
# host key.tpm: (absent)
# host key: /var/lib/r503d/key
# host key.bak: /var/lib/r503d/key.bak
# tpm device: (absent)
# allow-pair: (absent)Same flow, plus --seal-tpm. The generated key is sealed to PCR7
(Secure Boot policy + keys) and written to /var/lib/r503d/key.tpm
instead of the plaintext key file. Offline-disk attackers (dd of an
unmounted partition, SSD swap into a hostile host) get ciphertext only.
sudo systemctl stop r503d
sudo mkdir -p /etc/r503d
sudo touch /etc/r503d/allow-pair
sudo r503d --pair --seal-tpm # seals new key to current PCR7
sudo systemctl start r503dsudo r503d --status
# port: /dev/r503
# firmware: fw=1.1 fmt=2
# firmware paired: true
# firmware counter: 12
# host key.tpm: /var/lib/r503d/key.tpm
# host key: (missing)
# host key.bak: (missing)
# tpm device: /dev/tpmrm0
# allow-pair: (absent)Kernel updates, initrd updates, fwupd UEFI firmware updates, and grub2
updates do not change PCR7 and do not require a reseal. PCR7 only
changes on Secure Boot policy edits, MOK enrollments, or moving the disk
to a different host — at which point the daemon refuses to start with
TPM_RC_POLICY_FAIL and dist/reseal-tpm.sh recovers in ~90 seconds.
See Recovery: PCR7 changed.
# Enroll a finger (use KDE Settings → Users → Fingerprint Auth for a GUI):
fprintd-enroll mat
# Verify:
fprintd-verify mat
# sudo with finger:
sudo whoamiBoth KDE Settings (Plasma 6) and GNOME Control Center's user-account
fingerprint dialogs drive r503d exactly as they drive upstream fprintd.
If you want a fresh key (key compromised, planned hardware swap, paranoia):
sudo systemctl stop r503d
sudo r503d --unpair # framed; wipes Nano EEPROM + host key
sudo touch /etc/r503d/allow-pair
sudo r503d --pair # plaintext-key rotation
# - or -
sudo r503d --pair --seal-tpm # TPM-sealed rotation
sudo systemctl start r503dMatch your original pairing path. If you originally used --seal-tpm,
rotate with --seal-tpm — otherwise the rotation silently downgrades you
to a plaintext key on disk.
If you used --pair --seal-tpm and later changed something that PCR7
measures (Secure Boot turned off/on, new MOK enrolled, disk moved to
another box), the daemon will refuse to start with a journal message
about TPM_RC_POLICY_FAIL. Recovery is one command:
sudo bash pcside/daemon/dist/reseal-tpm.shThe script stops r503d, reflashes firmware/r503fp_wipe/ to wipe the
Nano EEPROM, reflashes the main firmware, creates /etc/r503d/allow-pair,
runs r503d --reseal-tpm to generate a fresh key sealed to the current
PCR7, and starts the daemon back up. Wall-clock: ~90 seconds. Enrolled
fingers are preserved — templates live on the R503 sensor's flash, not
the Nano.
The script needs arduino-cli available. If it's installed in your
user's $HOME/.local/bin it's auto-detected via $SUDO_USER; otherwise
set ARDUINO_CLI=/full/path/to/arduino-cli before running.
If the host key is intact but /var/lib/r503d/state.json is gone or rolled
back (restored an old backup, accidental rm), the daemon's counter falls
behind the Nano's last_seen and every framed command bounces off
ERR replay. r503d --status flags this; the fix is one command:
sudo systemctl stop r503d
sudo r503d --resync # reads Nano last_seen, sets host counter to last_seen+1
sudo systemctl start r503dNo re-pair, no reflash — the key never moves. The status query --resync
relies on is unauthenticated, but it can only move the host counter forward
to match what the Nano already committed, so it can never make an old frame
replayable (worst case a lying MITM forces another ERR replay, which it
could already do by garbling frames). See SPEC.md §13.11.
The authenticated --unpair needs the key to authorize. If all the
on-disk copies are gone (disk crash, accidental rm, both key + key.bak
deleted, or key.tpm blob lost), you need the reflash-to-wipe escape
hatch — same procedure that dist/reseal-tpm.sh automates for the
PCR7-changed case above:
sudo systemctl stop r503d
# /dev/r503 is root:root 0600 since install (audit H1), so the uploads need root.
sudo arduino-cli upload --fqbn arduino:avr:nano:cpu=atmega328 --port /dev/r503 firmware/r503fp_wipe/
# Wait ~1s for the wipe to complete (LED starts blinking — that's the wipe sketch).
sudo arduino-cli upload --fqbn arduino:avr:nano:cpu=atmega328 --port /dev/r503 firmware/r503fp/
sudo touch /etc/r503d/allow-pair
sudo r503d --pair
sudo systemctl start r503dIf sudo arduino-cli reports command-not-found (arduino-cli lives in your
~/.local/bin, not on root's PATH), run it as
sudo env "PATH=$PATH" arduino-cli … or give the absolute path.
This isn't a backdoor an attacker can use: re-pairing requires root on
the host (the opt-in file and the --pair CLI both need root), so a
reflashed Nano can't be brought into trust without you already being
root.
sudo bash pcside/daemon/dist/uninstall.shReverts everything, unmasks fprintd, leaves /var/lib/r503d/ (key,
state, users) in place in case you want to reinstall later. Delete that
directory manually if you want a true clean slate.
The Arduino runs a small ASCII-protocol firmware (firmware/r503fp/)
that talks the R503's native R30x ("Sync Word") binary protocol on its
UART side and exchanges line-oriented text commands with the host over
USB-CDC: ping, info, enroll N, verify, delete N, clear,
led off. Full v1 protocol in SPEC.md §5.
Since fw=1.0 (Milestone E of the v2 authenticated-channel work), every
command and response is wrapped in a C <counter> <body> M <mac> /
R <counter> <seq> <body> M <mac> frame, MAC'd with SipHash-2-4 over a
TOFU-paired 128-bit key. The Nano keeps a wear-leveled monotonic counter
in EEPROM; the daemon keeps a matching counter in /var/lib/r503d/state.json.
Replay attempts (firmware-side incoming <= last_seen) get rejected as
ERR replay; tampered frames get ERR mac_invalid. Full spec, threat
model, and known limitations in SPEC.md §13.
The Rust daemon (r503d) speaks D-Bus on net.reactivated.Fprint — bit-for-bit
the same interface upstream fprintd exposes — so every fprintd client
works unmodified. A JSON sidecar at /var/lib/r503d/users.json maps
(user, finger) to slot indices in the R503's internal flash.
Layout:
firmware/r503fp/ Arduino firmware (v2 framed ASCII protocol)
firmware/r503fp_wipe/ Emergency one-shot EEPROM wipe (lost-key recovery)
firmware/* Diagnostic / development sketches (ping, loopback, ...)
pcside/daemon/ Rust daemon (the fprintd replacement)
pcside/daemon/src/{crypto,framing,keystore,state,pairing}.rs
v2 wire protocol implementation
pcside/daemon/src/auth.rs caller-identity gating for D-Bus methods
pcside/daemon/dist/ udev rule, systemd unit, polkit + bus policy,
install scripts
docs/ Decision logs + troubleshooting
SPEC.md Full architecture + protocol spec (§13 = v2 auth)
The wire-level authentication targets a specific threat —
"evil maid with five minutes and a spare Nano" plus a hostile local
process on /dev/r503 — not nation-states or hardware attackers with
labs. Single-user desktop deployment with a documented out-of-scope list.
The full threat model lives in SPEC.md §13.1; the
implementation-and-review evidence lives in
docs/REVIEW-2026-05-28.md. A separate
adversarial privilege-escalation audit (2026-05-28) and its per-claim
validation/remediation pass are at
docs/SECURITY-AUDIT-2026-05-28.html
and
docs/SECURITY-AUDIT-2026-05-28-VALIDATION.html.
Defended:
- Hot-swap of the Nano with a hostile unit (no key → all frames fail MAC).
- Local process injecting fake match responses on
/dev/r503. Two layers: the device node isroot:root 0600(udev rule) and the daemon holds it withTIOCEXCL, so a non-root process can't open it — and even if it could, it has no key, so the frame fails MAC verify. - Replay of recorded
OK match=...frames in a future session. - Bit-flip tampering of any frame field (constant-time MAC compare).
- Counter-exhaustion brick: a peer (or a one-shot MITM during
--resync) driving the monotonic counter tou64::MAXand permanently wedging the channel is blocked by a reserved counter ceiling enforced on both ends (fw=1.1+; SPEC §13.4 / 2026-05-28 audit DoS-2). - Local-user denial-of-service of the sensor: a single capture-slot gate
caps in-flight enroll/verify work and the delete paths are action-gated,
so a
Start/Stop(or concurrent-delete) flood can't wedge auth. - Cross-user fingerprint plant / wipe / enumeration by a local non-root
user (e.g.
mallorycallingClaim "root"then enrolling her own finger) — caller identity is checked on everyusername-taking D-Bus method, and the system bus policy denies non-wheelcallers at the broker layer. - Offline-disk attacks on the host key when paired with
--seal-tpm: the key on disk is TPM2-sealed to PCR7, soddof an unmounted partition or SSD swap into a hostile host yields ciphertext only. Unwraps only on the same machine under the same Secure Boot policy. See SPEC §13.12.
Not defended:
- Host root compromise (key is in
/var/lib/r503d/key,0600 root:root). Root on a running host can unseal the TPM-sealed variant too — sealing blunts offline attacks, not online ones. - Physical attack on the Nano (EEPROM readback ~30 sec with ISP; chip decap; etc.).
- Firmware-reflash attack (the Arduino bootloader has no signing — but re-pairing requires root on the host, so a reflashed Nano can't be brought into trust without host compromise anyway).
- R503-side compromise (R30x protocol has no auth at all; out of our scope).
- Crypto posture. SipHash-2-4 MACs, 128-bit shared key, 64-bit MAC
output, domain-separated MAC inputs. Two independent implementations
(hand-rolled C++ on the AVR with boot-time KAT self-test; hand-rolled
Rust on the host, bit-for-bit cross-validated against the third-party
siphashercrate on 1024 random vectors in CI). Host MAC compare usessubtle::ConstantTimeEq. Wire parsers property-fuzzed on every CI run (~135 000 inputs).cargo auditclean. SipHash key wrapped inzeroize::Zeroizing<...>so it scrubs on drop (as are the per-frame MAC-input buffers). Acargo fuzzlibFuzzer target ships atpcside/daemon/fuzz/for long-corpus runs on nightly. No paid third-party human audit — that would still be valuable, PRs welcome.
Full threat model with rationale: SPEC.md §13.1.
- Multi-user works, but only for
wheelmembers. Caller identity is checked on every D-Bus method that takes ausername(Claim,EnrollStart,VerifyStart,ListEnrolledFingers,DeleteEnrolledFingers); self-requests anduid 0(PAM) succeed silently, cross-user from a non-root caller is denied withnet.reactivated.Fprint.Error.PermissionDenied. The system bus policy further restricts which accounts can even start a conversation: onlyrootandwheelmembers reach the daemon, everyone else getsorg.freedesktop.DBus.Error.AccessDeniedat the broker layer. Need cross-user enroll? Become root:sudo fprintd-enroll target-user. Need to loosen the cross-user gate for a kiosk / multi-user lab? Drop a JS rule into/etc/polkit-1/rules.d/targetingnet.reactivated.fprint.device.setusername— the action name mirrors upstream fprintd verbatim. - One reader. The daemon exposes a single Device object on D-Bus. Multi-reader setups need an extension to the Manager.
- No
PropertiesChangedemit for thefinger-present/finger-neededhint properties. Every common fprintd client (PAM, KDE Settings, GNOME) drives offEnrollStatus/VerifyStatussignals (which are emitted), not those polled hints — but a strict client that doesGet + PropertiesChangedwill see stale values. - Single Nano = single point of failure. If the Nano dies, fingerprint login is gone until you reflash a spare and re-pair. Keep a password auth method enabled as backup.
- State.json loss is recoverable in one command. If
state.jsonis lost while the firmware still has a highlast_seen, the daemon hitsERR replayon first send. Runsudo r503d --resyncto read the Nano's counter and realign the host — no re-pair needed. SeeSPEC.md§13.11.
# Daemon logs:
sudo journalctl -u r503d.service -f
# Confirm the sensor enumerates correctly:
ls -l /dev/r503
busctl --system call net.reactivated.Fprint /net/reactivated/Fprint/Device/0 \
net.reactivated.Fprint.Device ListEnrolledFingers s ""
# Confirm fprintd is masked and r503d owns the bus name:
systemctl is-enabled fprintd # should print "masked"
busctl --system list | grep -i fprintIf the daemon won't start or the sensor never responds, the most common
fix is the wiring — see SPEC.md §3, particularly the
"no voltage divider" note in §3.1. There's a more detailed runbook
in docs/TROUBLESHOOTING.md.
MIT — see LICENSE.
- Adafruit_Fingerprint — Arduino-side R30x protocol implementation.
- zbus, serialport-rs, tokio — the Rust D-Bus / serial / async stack.
- The
fprintdproject — for designing a clean D-Bus interface that this daemon could implement against without ever readinglibfprint's source.
