
Login por impressão digital no desktop Linux usando um sensor Grow R503 + Arduino + um daemon substituto do fprintd escrito em Rust
Um leitor de impressão digital USB montado a partir de peças para desktops Linux. Custo total das peças inferior a US$ 15. Substituição direta do fprintd upstream — PAM, KDE Settings, GNOME Settings, fprintd-verify, sudo com o dedo, desbloqueio de tela com o dedo — tudo funciona.
A partir de fw=1.0 / r503d 1.0.0 o link Arduino↔host é autenticado: cada comando e resposta carrega um MAC SipHash-2-4 vinculado a um segredo pareado via TOFU na EEPROM. Ataques de replay e de troca a quente contra o link serial USB são bloqueados. Consulte SPEC.md §13 para o design completo, incluindo o que o modelo de ameaças não cobre.

quem me dera ter uma impressora 3D…``` ┌──────────┐ 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, …
## Motivação
Leitores de impressão digital USB para Linux são escassos, caros e os
que existem (Validity, Synaptics, etc.) dependem de drivers libfprint
instáveis, criados por engenharia reversa, que quebram com atualizações
de firmware do fornecedor. O protocolo do Grow R503 é **público**, o lado
do Arduino é código seu, e a camada de compatibilidade libfprint é apenas
D-Bus.
Você também acaba com um leitor de impressão digital cujo código-fonte
você pode ler de cima a baixo.
## Lista de materiais
| Peça | Notas | Custo aproximado |
|------|-------|------|
| Sensor de impressão digital capacitivo Grow R503 | O redondo com o anel RGB | ~$10 |
| Placa Arduino Uno R3 / Nano / Mega / qualquer placa ATmega328 | Qualquer placa que rode SoftwareSerial | $5–$25 |
| 4–6 fios jumper | Dupont / protoboard | trivial |
## Ligação```
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)
Se o seu R503 vier com o conector JST-SH, corte um pigtail JST-SH para Dupont de 6 pinos para separar os fios. O fio marrom às vezes é verde, dependendo do vendedor — verifique em relação ao fio que vai para o pino RXD do conector JST, não pela cor.
Testado no Fedora 44 KDE; deve funcionar em qualquer distro baseada em systemd com fprintd, pam_fprintd e uma toolchain Rust recente.
Pacotes do sistema:
| Distro | Compilação | Tempo de execução |
|---|---|---|
| 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 |
Os pacotes tss-esapi só são necessários se você planeja usar --pair --seal-tpm (SPEC §13.12). O daemon compila e executa sem um TPM caso contrário — tss-esapi é uma dependência de compilação obrigatória, mas uma dependência de execução opcional (o caminho de código só é percorrido quando /var/lib/r503d/key.tpm existe).
Rust 1.95+, arduino-cli no seu $PATH.
Você tem um TPM2?```bash ls /dev/tpmrm0 && tpm2_pcrread sha256:7 | head -3
Se ambos forem bem-sucedidos, seu host poderá usar o caminho de chave selada. Se `/dev/tpmrm0` estiver ausente (hardware mais antigo, TPM desabilitado na BIOS ou uma VM sem TPM virtual), continue com o fluxo padrão de chave em texto puro.
## Compilação e instalação
### 1. Grave o firmware
Abra `firmware/r503fp/r503fp.ino` na IDE do Arduino e carregue. Ou com `arduino-cli`:```bash
# 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`
O firmware usa Adafruit_Fingerprint. A IDE se oferecerá para instalá-lo
na primeira compilação.
Se arduino-cli upload falhar com not in sync: resp=0x7e, o seu bootloader
é a outra variante — troque atmega328 ↔ atmega328old e tente novamente. Ambos
funcionam; a diferença é apenas a taxa de transmissão do bootloader.
Requer Rust 1.95+.```bash cd pcside/daemon cargo build --release
### 3. Instalação```bash
sudo bash pcside/daemon/dist/install.sh
Esse script:
target/release/r503d em /usr/local/bin/r503d/var/lib/r503d/ (modo 0700 root:root) para a chave, o estado e o
registro de slots de usuário/dev/r503 e bloqueia o
nó de dispositivo para root:root 0600 (somente o daemon, executando como root, precisa disso;
isso fecha o caminho padrão 0660 root:dialout para que nenhum outro usuário local possa
abrir a porta — auditoria de segurança 2026-05-28 / H1). Consequência: após a
instalação, qualquer comando manual de arduino-cli/serial-monitor contra /dev/r503
exige sudo./etc/systemd/system/r503d.service)net.reactivated.Fprint/usr/share/polkit-1/actions/net.reactivated.fprint.device.r503d.policy)
usada pela barreira de identidade do chamadorÉ idempotente — execute-o novamente após cada cargo build --release para
reimplementar o novo binário.
Um Nano recém-gravado não está emparelhado — o daemon falaria com ele, mas o firmware rejeitaria cada comando enquadrado. Escolha um dos dois fluxos abaixo; ambos terminam com um Nano emparelhado e um daemon em funcionamento. O fluxo com selo TPM é recomendado se o seu host tiver um TPM2 (consulte os Pré-requisitos para a verificação rápida).
O arquivo opt-in (/etc/r503d/allow-pair) usado em ambos os fluxos existe para
derrotar um atacante que corre até a sua mesa com o próprio Nano — o emparelhamento
sem root é impossível. r503d --pair exclui o marcador antes de
enviar a chave para o Nano: se o host travar entre a confirmação no lado do Nano e a
persistência no lado do host, a barreira já está fechada, então a próxima tentativa
de emparelhamento exige que um administrador execute touch no marcador novamente. Uma saída
antes do envio (sem marcador, ou "já emparelhado") deixa o marcador intacto para nova tentativa.
Use isto se você não tiver um dispositivo TPM2, ou se não precisar de resistência a ataques a disco offline.```bash 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 r503d
...```bash
sudo 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)
Mesmo fluxo, mais --seal-tpm. A chave gerada é selada para PCR7
(política de Secure Boot + chaves) e gravada em /var/lib/r503d/key.tpm
em vez do arquivo key em texto puro. Atacantes com disco offline (dd de uma
partição desmontada, troca de SSD para um host hostil) obtêm apenas texto cifrado.```bash
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 r503d
The input chunk appears to be empty — no content was provided after "INPUT:". Please supply the chunk text to translate.```bash
sudo 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)
Atualizações de kernel, atualizações de initrd, atualizações de firmware UEFI via fwupd e atualizações do grub2 não alteram o PCR7 e não exigem reseal. O PCR7 só muda em edições da política do Secure Boot, inscrições de MOK ou ao mover o disco para outro host — ponto em que o daemon se recusa a iniciar com TPM_RC_POLICY_FAIL e dist/reseal-tpm.sh recupera em ~90 segundos. Veja Recuperação: PCR7 alterado.
fprintd-enroll mat
fprintd-verify mat
sudo whoami
Tanto as Configurações do KDE (Plasma 6) quanto os diálogos de impressão digital de conta de usuário do GNOME Control Center controlam `r503d` exatamente como controlam o `fprintd` upstream.
### Reemparelhamento / rotação de chave
Se você quiser uma chave nova (chave comprometida, troca de hardware planejada, paranoia):```bash
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 r503d
Corresponda ao seu caminho de emparelhamento original. Se você usou originalmente --seal-tpm,
faça a rotação com --seal-tpm — caso contrário, a rotação o rebaixa silenciosamente
para uma chave em texto simples no disco.
Se você usou --pair --seal-tpm e depois alterou algo que o PCR7
mede (Secure Boot desligado/ligado, novo MOK registrado, disco movido para
outra máquina), o daemon se recusará a iniciar com uma mensagem no journal
sobre TPM_RC_POLICY_FAIL. A recuperação é um único comando:```bash
sudo bash pcside/daemon/dist/reseal-tpm.sh
O script para o `r503d`, regrava `firmware/r503fp_wipe/` para limpar a EEPROM do Nano, regrava o firmware principal, cria `/etc/r503d/allow-pair`, executa `r503d --reseal-tpm` para gerar uma nova chave selada ao PCR7 *atual* e reinicia o daemon. Tempo real: ~90 segundos. As impressões digitais cadastradas são preservadas — os templates ficam na flash do sensor R503, não no Nano.
O script precisa do `arduino-cli` disponível. Se estiver instalado em `$HOME/.local/bin` do seu usuário, ele é detectado automaticamente via `$SUDO_USER`; caso contrário, defina `ARDUINO_CLI=/full/path/to/arduino-cli` antes de executar.
### Recuperação: `state.json` perdido (dessincronização do contador)
Se a chave do host estiver intacta, mas `/var/lib/r503d/state.json` tiver sumido ou sido revertido (restaurado de um backup antigo, `rm` acidental), o contador do daemon fica atrás do `last_seen` do Nano e todo comando enquadrado é rejeitado com `ERR replay`. O `r503d --status` sinaliza isso; a correção é um único comando:```bash
sudo systemctl stop r503d
sudo r503d --resync # reads Nano last_seen, sets host counter to last_seen+1
sudo systemctl start r503d
Sem re-pareamento, sem reflash — a chave nunca se move. A consulta status da qual --resync depende não é autenticada, mas ela só pode mover o contador do host para frente para corresponder ao que o Nano já confirmou, portanto nunca pode tornar um quadro antigo reproduzível (no pior caso, um MITM mentiroso força outro ERR replay, o que ele já poderia fazer ao corromper quadros). Veja SPEC.md §13.11.
O --unpair autenticado precisa da chave para autorizar. Se todas as cópias em disco sumiram (falha de disco, rm acidental, tanto key quanto key.bak deletados, ou o blob key.tpm perdido), você precisa da válvula de escape reflash-to-wipe — mesmo procedimento que dist/reseal-tpm.sh automatiza para o caso de PCR7 alterado acima:```bash
sudo systemctl stop r503d
sudo arduino-cli upload --fqbn arduino:avr:nano:cpu=atmega328 --port /dev/r503 firmware/r503fp_wipe/
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 r503d
Se `sudo arduino-cli` relatar command-not-found (o arduino-cli fica no seu
`~/.local/bin`, não no `PATH` do root), execute-o como
`sudo env "PATH=$PATH" arduino-cli …` ou forneça o caminho absoluto.
Isto não é uma backdoor que um invasor possa usar: o re-pareamento requer root no
host (o arquivo opt-in e a CLI `--pair` ambos precisam de root), então um
Nano reflashado não pode ser aceito como confiável sem que você já seja
root.
### Desinstalar```bash
sudo bash pcside/daemon/dist/uninstall.sh
Reverte tudo, desmascara fprintd, deixa /var/lib/r503d/ (chave,
estado, usuários) no lugar caso queira reinstalar mais tarde. Apague esse
diretório manualmente se quiser um estado realmente limpo.
O Arduino executa um pequeno firmware de protocolo ASCII (firmware/r503fp/)
que fala o protocolo binário nativo R30x ("Sync Word") do R503 no lado
UART e troca comandos de texto orientados por linha com o host via
USB-CDC: ping, info, enroll N, verify, delete N, clear,
led off. Protocolo v1 completo em SPEC.md §5.
Desde fw=1.0 (Marco E do trabalho de canal autenticado v2), todo
comando e resposta é envolvido em um quadro C <counter> <body> M <mac> /
R <counter> <seq> <body> M <mac> com MAC via SipHash-2-4 sobre uma
chave de 128 bits pareada por TOFU. O Nano mantém um contador monotônico
com nivelamento de desgaste na EEPROM; o daemon mantém um contador correspondente
em /var/lib/r503d/state.json. Tentativas de replay (no lado do firmware,
incoming <= last_seen) são rejeitadas como ERR replay; quadros adulterados
resultam em ERR mac_invalid. Especificação completa, modelo de ameaça e limitações conhecidas em SPEC.md §13.
O daemon Rust (r503d) fala D-Bus em net.reactivated.Fprint — exatamente
a mesma interface que o fprintd upstream expõe — então todo cliente fprintd
funciona sem modificações. Um sidecar JSON em /var/lib/r503d/users.json mapeia
(usuário, dedo) para índices de slot na flash interna do R503.
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)
## Modelo de segurança — resumo rápido
A autenticação em nível de protocolo visa uma ameaça específica —
**"empregada maliciosa com cinco minutos e um Nano sobressalente"** mais um processo local
hostil em `/dev/r503` — não estados-nação ou atacantes de hardware com
laboratórios. Implantação desktop de usuário único com uma lista documentada de itens fora de escopo.
O modelo de ameaça completo vive em [`SPEC.md` §13.1](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/SPEC.md); as
evidências de implementação e revisão vivem em
[`docs/REVIEW-2026-05-28.md`](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/docs/REVIEW-2026-05-28.md). Uma auditoria
adversarial separada de escalonamento de privilégios (2026-05-28) e sua
passagem de validação/remediação por alegação estão em
[`docs/SECURITY-AUDIT-2026-05-28.html`](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/docs/SECURITY-AUDIT-2026-05-28.html)
e
[`docs/SECURITY-AUDIT-2026-05-28-VALIDATION.html`](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/docs/SECURITY-AUDIT-2026-05-28-VALIDATION.html).
**Defendido:**
- Troca a quente do Nano por uma unidade hostil (sem chave → todos os quadros falham no MAC).
- Processo local injetando respostas falsas de correspondência em `/dev/r503`. Duas camadas:
o nó de dispositivo é `root:root 0600` (regra udev) e o daemon o mantém
com `TIOCEXCL`, então um processo não-root não pode abri-lo — e mesmo se
pudesse, não tem chave, então o quadro falha na verificação MAC.
- Repetição (replay) de quadros `OK match=...` gravados em uma sessão futura.
- Adulteração por bit-flip de qualquer campo do quadro (comparação MAC em tempo constante).
- Travamento por esgotamento de contador: um par (ou um MITM de uso único durante `--resync`)
conduzindo o contador monotônico a `u64::MAX` e travando permanentemente o
canal é bloqueado por um teto de contador reservado aplicado em ambas as extremidades
(`fw=1.1+`; auditoria SPEC §13.4 / 2026-05-28 DoS-2).
- Negação de serviço por usuário local contra o sensor: uma comporta de slot de captura única
limita o trabalho de enroll/verify em andamento e os caminhos de exclusão são controlados por ação,
então uma enxurrada de `Start`/`Stop` (ou exclusões concorrentes) não pode travar a autenticação.
- Inserção / remoção / enumeração de impressões digitais entre usuários por um usuário local
não-root (ex.: `mallory` chamando `Claim "root"` e então cadastrando o próprio
dedo) — a identidade do chamador é verificada em todo método D-Bus que recebe `username`,
e a política do barramento do sistema nega chamadores não-`wheel` na
camada do broker.
- **Ataques offline ao disco contra a chave do host** *quando combinado com `--seal-tpm`*:
a chave em disco é selada por TPM2 para PCR7, então um `dd` de uma partição
desmontada ou a troca de SSD para um host hostil produz apenas texto cifrado.
Só é desembrulhada na mesma máquina sob a mesma política Secure Boot.
Veja [SPEC §13.12](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/SPEC.md).
**Não defendido:**
- Comprometimento de root no host (a chave está em `/var/lib/r503d/key`, `0600 root:root`).
O root em um host em execução também pode deselar a variante selada por TPM — a selagem
atenua ataques *offline*, não os online.
- Ataque físico ao Nano (leitura da EEPROM ~30 s com ISP; decapagem do chip; etc.).
- Ataque de regravação de firmware (o bootloader do Arduino não tem assinatura — mas
o re-pareamento exige root no host, então um Nano regravado não pode ser
estabelecido como confiável sem comprometimento do host de qualquer forma).
- Comprometimento do lado do R503 (o protocolo R30x não tem autenticação alguma; fora do nosso escopo).
- **Postura criptográfica.** MACs SipHash-2-4, chave compartilhada de 128 bits, saída MAC
de 64 bits, entradas MAC separadas por domínio. Duas implementações independentes
(C++ implementado manualmente no AVR com autoteste KAT na inicialização; Rust
implementado manualmente no host, validado bit a bit contra o crate `siphasher`
de terceiros em 1024 vetores aleatórios no CI). A comparação MAC no host usa
`subtle::ConstantTimeEq`. Os parsers de protocolo são submetidos a fuzzing de propriedades em toda execução de CI
(~135 000 entradas). `cargo audit` limpo. A chave SipHash é envolvida em
`zeroize::Zeroizing<...>` para que seja apagada no drop (assim como os buffers
de entrada MAC por quadro). Um alvo libFuzzer de `cargo fuzz`
é fornecido em `pcside/daemon/fuzz/` para execuções com corpus longo no
nightly. Nenhuma auditoria humana paga de terceiros — isso ainda seria
valioso, PRs são bem-vindos.
Modelo de ameaça completo com justificativa: [`SPEC.md` §13.1](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/SPEC.md).
## Limitações
- **Multiusuário funciona, mas apenas para membros de `wheel`.** A identidade do chamador é
verificada em todos os métodos D-Bus que recebem um `username` (`Claim`,
`EnrollStart`, `VerifyStart`, `ListEnrolledFingers`,
`DeleteEnrolledFingers`); solicitações para si mesmo e `uid 0` (PAM) têm sucesso
silencioso, enquanto a solicitação entre usuários a partir de um chamador não-root é negada com
`net.reactivated.Fprint.Error.PermissionDenied`. A política do barramento do sistema
restringe ainda mais quais contas podem sequer iniciar uma conversa: apenas
`root` e membros de `wheel` alcançam o daemon; todos os demais recebem
`org.freedesktop.DBus.Error.AccessDenied` na camada do broker.
Precisa de enroll entre usuários? Torne-se root: `sudo fprintd-enroll target-user`.
Precisa afrouxar a portaria entre usuários para um quiosque / laboratório multiusuário? Coloque
uma regra JS em `/etc/polkit-1/rules.d/` direcionada a
[`net.reactivated.fprint.device.setusername`](https://gitlab.freedesktop.org/libfprint/fprintd/-/blob/master/src/net.reactivated.fprint.device.policy.in)
— o nome da ação espelha o fprintd upstream literalmente.
- **Um leitor.** O daemon expõe um único objeto Device no D-Bus.
Configurações com vários leitores precisam de uma extensão do Manager.
- **Nenhuma emissão de `PropertiesChanged`** para as propriedades de dica
`finger-present` / `finger-needed`. Todos os clientes fprintd comuns (PAM, KDE Settings, GNOME)
usam os sinais `EnrollStatus` / `VerifyStatus` (que são emitidos),
e não essas dicas pesquisadas — mas um cliente estrito que faça
`Get + PropertiesChanged` verá valores obsoletos.
- **Um único Nano = ponto único de falha.** Se o Nano morrer, o login por impressão
digital desaparece até você regravar um sobressalente e parear novamente. Mantenha um método
de autenticação por senha habilitado como reserva.
- **A perda de `state.json` é recuperável com um único comando.** Se `state.json` for perdido
enquanto o firmware ainda tem um `last_seen` alto, o daemon recebe `ERR replay`
no primeiro envio. Execute `sudo r503d --resync` para ler o contador do Nano e
realinhar o host — nenhum re-pareamento é necessário. Veja [`SPEC.md` §13.11](https://github.com/matpb/linux-fingerprint-r503/blob/HEAD/SPEC.md).
## Solução de problemas```bash
# 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 fprint
Se o daemon não iniciar ou o sensor nunca responder, a correção mais
comum é a fiação — veja SPEC.md §3, particularmente a
nota "sem divisor de tensão" na §3.1. Há um runbook mais detalhado
em docs/TROUBLESHOOTING.md.
MIT — veja LICENSE.
fprintd — por projetar uma interface D-Bus limpa que
este daemon pudesse implementar sem nunca ler o código-fonte do
libfprint./etc/dbus-1/system.d/net.reactivated.Fprint.conf) — somente membros de root e
wheel podem falar com o daemon; todos os outros recebem
AccessDenied no broker, antes de o daemon ver a chamadafprintd.service upstreamr503d.service