fix(nfc): make the Bolt Card reader an opt-in machine capability #115

Merged
padreug merged 2 commits from fix/nfc-opt-in into dev 2026-09-29 20:51:37 +00:00
5 changed files with 137 additions and 95 deletions
Showing only changes of commit bb2ad39628 - Show all commits

feat(deploy): services.bitspire.nfc.enable — declare the reader per machine

pcscd was enabled in hardware/batm3.nix and hardware/upboard.nix, which
cannot express "is a reader fitted": upboard.nix is shared by sintra (HID
Global OMNIKEY 5022) and tejo (nothing fitted), so tejo inherited pcscd it
has no use for, while the douro — with its own hardware file — got none and
wedged on every boot.

Make it a machine capability instead. services.bitspire.nfc.enable owns
pcscd, the two polkit rules and the wedge-recovery unit, and hands the app
a BITSPIRE_NFC_ENABLED flag so it doesn't initialise nfc-pcsc at all on a
machine with no reader. Per-model truth lives in nfcReaderForModel in
flake.nix next to fiatCodeForModel and upgradeWindowForModel, since a
shared hardware file can't answer the question. batm3 and sintra are true;
douro and tejo flip to true when readers are fitted.

The flag goes through the unit's Environment rather than
/var/lib/bitspire/.env, because .env is only written when absent — a
machine provisioned months ago would never pick up a new value.
Padreug 2026-09-29 22:49:59 +02:00

View file

@ -952,22 +952,29 @@ app.whenReady().then(() => {
startWatchdog() startWatchdog()
startCommandPoller() startCommandPoller()
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in
// best-effort: if the reader/pcscd is absent it just reports 'unavailable' // per machine via services.bitspire.nfc.enable, which is off unless a CCID
// and the cash-out QR path is unaffected. // reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very
void startNfcReader( // thread when pcscd is absent and wedges the whole app (see nfc-service.ts).
(lnurlw) => { // nfc-service also re-checks the pcscd socket, so this flag is the coarse
// Don't log the value — it carries the card's single-use SUN p/c. // gate, not the only defence. Cash-out via QR never depends on any of it.
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) if (process.env.BITSPIRE_NFC_ENABLED === 'true') {
mainWindow?.webContents.send('nfc:card-tapped', lnurlw) void startNfcReader(
}, (lnurlw) => {
(status: NfcStatus) => { // Don't log the value — it carries the card's single-use SUN p/c.
console.log( console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
) },
mainWindow?.webContents.send('nfc:status', status) (status: NfcStatus) => {
} console.log(
) `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
)
mainWindow?.webContents.send('nfc:status', status)
}
)
} else {
console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init')
}
app.on('activate', () => { app.on('activate', () => {
// macOS: re-create window when dock icon clicked // macOS: re-create window when dock icon clicked

View file

@ -132,6 +132,28 @@ in
description = "Camera device"; description = "Camera device";
}; };
}; };
# Contactless (CCID) card reader for Bolt Card taps — ADR-003.
#
# Opt-in, and deliberately defaulted off: only some machines have a reader
# fitted, and on a machine without one the app must not so much as
# initialise nfc-pcsc, because pcsclite busy-spins Electron's main thread
# when pcscd is absent (the full story is in nfc-service.ts). Per-model
# truth lives in `nfcReaderForModel` in flake.nix, since sintra and tejo
# share hardware/upboard.nix but only one of them has a reader.
nfc = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable the Bolt Card reader. Starts pcscd, authorises the `bitspire`
user to talk to it and to the card via polkit, installs the
wedge-recovery unit, and tells the app to initialise NFC at all.
Leave false on machines with no reader fitted; cash-out over QR is
unaffected either way.
'';
};
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@ -171,6 +193,70 @@ in
ELECTRON_DISABLE_GPU=false ELECTRON_DISABLE_GPU=false
''; '';
# ── Bolt Card reader (services.bitspire.nfc.enable) ─────────────────
# Lifted out of hardware/batm3.nix and hardware/upboard.nix so that "is a
# reader fitted" is one per-machine flag rather than a block copied into
# each hardware file — upboard.nix is shared by sintra (OMNIKEY 5022) and
# tejo (no reader), so a hardware file cannot answer the question.
# pcscd binds the CCID driver to the reader; the app talks to pcscd's
# socket via nfc-pcsc rather than the USB device directly. Reader-agnostic
# (Feitian KP382 on batm3, HID Global OMNIKEY 5022 on sintra).
services.pcscd.enable = mkIf cfg.nfc.enable true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client".
# Authorise it to talk to the daemon and the card, and to trigger the
# wedge-recovery unit below.
security.polkit.extraConfig = mkIf cfg.nfc.enable ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "nfc-reader-reset.service" &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# NFC reader wedge-recovery. A CCID reader (the Feitian R502-CL especially)
# can wedge: it keeps detecting a card but every APDU returns "card absent
# or mute", and ONLY a USB power-cycle clears it — restarting pcscd or the
# app does not. This oneshot re-binds the reader's USB device (a software
# replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
# restart (verified on-device). The app (unprivileged `bitspire`) starts it
# via the polkit rule above when it sees repeated read failures. Matches
# the USB CCID interface class (0x0B), so a future reader swap needs no
# config change.
systemd.services.nfc-reader-reset = mkIf cfg.nfc.enable {
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
set -u
found=0
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
[ -f "$iface" ] || continue
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
dev=''${ifname%%:*}
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
${pkgs.coreutils}/bin/sleep 2
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
found=1
done
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
'';
};
};
# Main ATM service # Main ATM service
systemd.services.bitspire = { systemd.services.bitspire = {
description = "bitSpire ATM Application"; description = "bitSpire ATM Application";
@ -181,6 +267,11 @@ in
]; ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
# Read by electron/main.ts. Lives in the unit rather than the .env
# EnvironmentFile because .env is only written when absent, so a machine
# provisioned months ago would never pick a new value up.
environment.BITSPIRE_NFC_ENABLED = boolToString cfg.nfc.enable;
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = "bitspire"; User = "bitspire";

View file

@ -85,65 +85,10 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID # The Feitian KP382 contactless reader (096e:0608) is declared as a machine
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it # capability, not here: `nfcReaderForModel` in flake.nix drives
# binds the CCID driver to the reader; the app talks to pcscd's socket (via # services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the
# nfc-pcsc) rather than the USB device directly. Harmless if no reader is # wedge-recovery unit (deploy/nixos/bitspire-atm.nix).
# attached — pcscd just idles.
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card. The second rule lets the app trigger
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "nfc-reader-reset.service" &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often,
# any CCID reader) can wedge: it keeps detecting a card but every APDU returns
# "card absent or mute", and ONLY a USB power-cycle clears it — restarting
# pcscd or the app does not. This oneshot re-binds the reader's USB device (a
# software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
# restart (verified on-device). The app (unprivileged `bitspire`) starts it via
# the polkit rule above when it sees repeated read failures. Reader-agnostic:
# it matches the USB CCID interface class (0x0B), so it also covers a future
# ACR1252U swap without a config change.
systemd.services.nfc-reader-reset = {
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
set -u
found=0
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
[ -f "$iface" ] || continue
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
dev=''${ifname%%:*}
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
${pkgs.coreutils}/bin/sleep 2
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
found=1
done
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
'';
};
};
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {

View file

@ -91,26 +91,9 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader # No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter # fitted) and tejo (no reader), so the reader is declared per model via
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket # `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable.
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card.
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {

View file

@ -144,6 +144,21 @@
sintra = "04:00 Europe/Paris"; sintra = "04:00 Europe/Paris";
}; };
# Which models have a contactless (CCID) card reader fitted, for Bolt
# Card taps. Same keying caveat as the two tables above.
#
# This can't live in a hardware file: hardware/upboard.nix is shared by
# sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before
# this table the tejo inherited pcscd it had no use for while the douro,
# with its own hardware file, got none and wedged on boot — nfc-pcsc
# busy-spins Electron's main thread when pcscd is absent (see
# apps/machine/electron/nfc-service.ts). Flip a model to true when a
# reader is actually fitted; douro and tejo are planned.
nfcReaderForModel = {
batm3 = true; # Feitian KP382
sintra = true; # HID Global OMNIKEY 5022
};
lib = nixpkgs.lib; lib = nixpkgs.lib;
# Helper to create a live USB NixOS config for a specific machine model # Helper to create a live USB NixOS config for a specific machine model
@ -200,6 +215,7 @@
services.bitspire = { services.bitspire = {
enable = true; enable = true;
appDir = "${atm-app}"; appDir = "${atm-app}";
nfc.enable = nfcReaderForModel.${machineModel} or false;
}; };
# Operator TUI and CLI tools # Operator TUI and CLI tools