One short, stable ID for every smart card you tap. Any card, any reader, pure Go.
NFC · MIFARE · RFID · ISO 14443 · eID · YubiKey · contact cards — anything pcscd manages.
Tap a card. Get an ID like r3v-401-5gmr. Tap it again tomorrow, on a different machine, through a different reader — same ID, every time. Two cards of the same model? Different IDs. That's the whole idea, and it's what makes pcscid the missing piece between "a card was presented" and "this specific card was presented".
No cgo. No libpcsclite. No dependencies. One static binary that talks the pcscd daemon wire protocol itself, plus a tiny loopback HTTP bridge so even a browser kiosk page can react to card scans.
$ ./pcscid
#qr-xlrk-i5:r3v-401-5gmr # reader tag, btag; # marks a btag lineEvery other smart card tooling path funnels you through C bindings, type detection, or both. pcscid takes a different bet:
- Identity, not just type detection. The ATR alone tells you a MIFARE Classic 1K was tapped — every card of that model shares it.
pcscidreads the card's own anti-collision UID through the PC/SC part 3GET DATAAPDU (FF CA 00 00 00) and folds it into a short btag:xxx-xxx-xxxx, digits and lowercase letters, stable across readers, machines, daemon restarts and USB ports. - Pure Go, zero cgo, zero dependencies. The pcscd IPC protocol (framed requests, raw struct responses) is implemented from scratch against pcsc-lite 1.8.24 through 2.4.x, including version down-negotiation for old daemons. One static binary, nothing to link, nothing to break.
- Hardware-free tests. A second, independent in-process implementation of the whole wire protocol acts as a fake
pcscd. Client and fake agreeing is itself under test —make testneeds no reader, no card, runs fully parallel. - Privacy cards handled correctly. ISO/IEC 14443-3 random UIDs (phone NFC emulation, eID, newer DESFire — a new UID per activation) are detected and rejected as identifiers. A btag always requires a valid UID: a card whose UID cannot be read produces no output line at all (silently in normal mode, with the full diagnostic trace under
DEBUG=1), never a type level ATR identity.Card.Source(uid/none) tells you which.
ID = alnum( SHA-256("pcscid/v1|" + card-type + "|" + uid) )[:10] # → xxx-xxx-xxxx
| Ingredient | Meaning |
|---|---|
card-type |
detected from the ATR: PC/SC part 3 contactless table (mifare classic 1k, mifare ultralight ev1, felica, picopass 16k, …), known full ATRs (german eid/passport (npa), yubikey 5 nfc, deutschlandticket (vdv-ka)), or unknown |
uid |
the card's own unique tag (4/7/10 bytes). Required: without a valid UID no btag is served at all (failed read or random UID) — the presentation is skipped silently in normal mode, DEBUG=1 traces every detail. A type level ATR identity is never printed |
The derivation is a pure function of card type + tag: no timestamps, no reader names, no machine state. The same card produces the same btag everywhere, forever — the digest is pinned by golden tests, so it can never change silently on you.
Readers get the same treatment, with three tiers in decreasing portability:
| Tier | Input | Tag domain | Stable across | Enabled by |
|---|---|---|---|---|
| Model | normalized pcscd reader name (volatile hotplug indices stripped) | pcscid/reader/v1 |
everything | always |
| Unit: hardware serial | SCARD_ATTR_VENDOR_IFD_SERIAL_NO, the USB iSerial burned into the unit |
pcscid/reader/v2 |
machines, ports, daemon restarts | always (when the driver serves one) |
| Unit: USB port path | SCARD_ATTR_CHANNEL_ID → sysfs devpath (e.g. 2-1.3), or the sysfs CCID device scan when the driver serves no channel id |
pcscid/reader/v3 |
daemon restarts, reboots, same port | PCSCID_USB_PATH_ID=1 |
| Machine scope | MachineID(), the MAC addresses of the physical ethernet ports |
pcscid/reader/m1 |
everything within one machine | PCSCID_MAC_ID=1 |
Two units of the same model that report no usable serial — the ACS ACR122U family ships the same all-zero iSerial on every unit — collide on the model tag. The two options close that gap, opt-in because each trades portability:
# Anchor serial-less readers to their physical USB port:
# distinct tag per unit, stable as long as it stays in its port.
$ PCSCID_USB_PATH_ID=1 ./pcscid
# Scope every reader tag to this machine's hardware MACs:
# identical readers on different kiosks get distinct tags.
$ PCSCID_MAC_ID=1 ./pcscid
# Fleet of kiosks, one ACR122U per port: combine both.
$ PCSCID_USB_PATH_ID=1 PCSCID_MAC_ID=1 ./pcscidThe MAC filter takes only physical ethernet ports (sysfs type 1, a backing device, no phy80211): wifi, loopback, bridges, bonds, vlans and veth never enter the machine identity. The unit facts travel in Event.ReaderSerial / Event.ReaderPort, the composed tag in Event.ReaderTag. On the ACR122U specifically, neither the iSerial nor any NVRAM field is writable by host software — the port anchor is the only software-only per-unit identity that hardware has.
Port resolution is two staged: the driver's channel id first, then a sysfs scan for the reader's CCID device by name (manufacturer and product strings, bInterfaceClass 0x0B).
N identical units (same model, empty iSerial, no channel id) are told apart by their USB traffic. A plain card connection submits no URBs at all (the daemon already powered the card, the attribute answers come from driver memory), so the probe window carries the card's UID exchange plus pinning exchanges — real CCID bulk round trips — and the unit probe snapshots every candidate device's sysfs urbnum counter before and after them; the counter that moved belongs to the probed unit. That correlation is exact and independent of the daemon's reader order, so the same physical reader on the same full USB port path keeps its tag across service restarts, daemon restarts and reboots. A counter that does not single one device out refuses instead of guessing — unless every other candidate is already owned by another reader of the session, then the one free device is the unit's (elimination).
The session's unit registry keeps the identity persistent and collision free. A resolved port belongs to exactly one reader name, a second reader never adopts it; a transient probe failure falls back to the remembered identity, so a reader's tag cannot flip between two presentations of the same card; and the registry is dropped on daemon reconnect, because a restart re-enumerates the volatile name suffixes. A multi-slot unit (one USB device, one reader per slot) qualifies the port with the pcscd slot group of the reader name (2-1.3#01), the first slot keeps the plain devpath so existing tags stay stable.
$ make build
$ ./pcscid
#qr-xlrk-i5:r3v-401-5gmrOne line per presentation: #, the reader tag, a colon, the btag. Nothing else — stdout is machine readable by design; the leading # marks a btag line. A presentation whose UID could not be read prints nothing: no btag without a valid UID, the failure is only visible in the DEBUG=1 trace.
At startup stderr carries the reader inventory: every reader registered with pcscd is evaluated once and printed with its details and hashes — reader name, model tag, per-unit facts (the USB port path resolves card-less when it is unambiguous, the hardware serial needs a presented card), the portability tier, the effective tag under the configured options, and the identification of a card that is already present. A present card without a readable UID is reported as card="present without a valid uid, no btag", never with an ATR derived tag:
$ ./pcscid
time=... level=INFO msg="reader identified" reader="ACS ACR122U 01 00 00" \
model=qr-xlrk-i5 tag=be-4t2k-pm tier=serial serial=A001 \
card=r3v-401-5gmr card_type="mifare classic 1k" card_source=uidA reader without a card cannot be probed for its hardware serial (that needs the card connection); with PCSCID_USB_PATH_ID=1 its USB port is still pinned from the sysfs device scan when it is unambiguous, otherwise it reports the model level tag until its first scan upgrades the identity.
DEBUG=1 turns on a full verbose trace on stderr while stdout stays clean:
$ DEBUG=1 ./pcscid
time=... level=DEBUG msg="pcscd connected" socket=/run/pcscd/pcscd.comm version=4.5
time=... level=DEBUG msg="card inserted" reader="ACS ACR122U 00 00" \
id=r3v-401-5gmr reader-tag=qr-xlrk-i5 type="mifare classic 1k" source=uid \
uid="04 11 22 33" protocol=T=1
#qr-xlrk-i5:r3v-401-5gmrWhen the UID read fails, DEBUG=1 keeps the whole picture — the per-exchange trace and one summary record with every fact — while normal mode prints nothing at all:
$ DEBUG=1 ./pcscid
time=... level=DEBUG msg="uid apdu rejected" reader="ACS ACR122U 00 00" attempt=3 sw="63 00"
time=... level=DEBUG msg="uid unreadable after every round, the card presentation is skipped (no btag without a valid uid)" \
reader="ACS ACR122U 00 00" rounds=3 …
time=... level=DEBUG msg="card presentation skipped, no btag without a valid uid" \
reader="ACS ACR122U 00 00" reader-tag=qr-xlrk-i5 type="mifare classic 1k" \
uid="" atr="3B 8F …" protocol=T=1 \
consequence="no reader/btag line is printed for this presentation"./pcscid -version prints the build-time semver (injected from the latest git tag by make build).
Set PCSCID_SIGN_KEY to the path of a usable, passphrase-less ssh-ed25519 private key and every output line is extended by $ and the base64 SSHSIG signature of the line itself, the signature format of ssh-keygen -Y sign:
$ PCSCID_SIGN_KEY=/etc/pcscid/id_ed25519 ./pcscid
#qr-xlrk-i5:r3v-401-5gmr$U1NIU0lHAAAAAQAAA…A kiosk's consumers can prove every line came from that machine. Verify with the stock ssh-keygen (the base64 blob is the armored block's payload, wrapped at 70 columns):
$ line='#qr-xlrk-i5:r3v-401-5gmr$U1NIU0lHAAAAAQAAA…'
$ printf '%s' "${line%%\$*}" > msg # the message: everything before the $
$ (echo "-----BEGIN SSH SIGNATURE-----"; \
echo "${line#*\$}" | fold -w 70; echo "-----END SSH SIGNATURE-----") > sig
$ echo "kiosk $(cat id_ed25519.pub)" > allowed_signers
$ ssh-keygen -Y verify -f allowed_signers -I kiosk -n pcscid -s sig < msg
Good "pcscid" signature for kiosk with ED25519 key SHA256:…Mind the \$ escapes: an unescaped ${line%%$*} expands $* (the shell's positional parameters) instead of matching the literal $, and the verification fails. The signature covers the exact bytes of the line (#<reader-tag>:<btag>), the namespace is pcscid.
A configured but unusable key (encrypted, wrong type, damaged) fails the startup instead of silently producing unsigned output.
A browser sandbox cannot open /run/pcscd/pcscd.comm — no Unix sockets from WASM or page JavaScript, no WebUSB/WebHID in Firefox. Set PCSCID_HTTP_ADDR and the same binary additionally serves every presentation's reader tag and btag as loopback HTTP, CORS-permissive so an HTTPS kiosk page may read http://127.0.0.1:8976 without mixed content trouble:
| Endpoint | Purpose |
|---|---|
GET /health |
{"ok":true,"version":...} liveness probe |
GET /events |
SSE stream: hello event, then one card event per scan, 20 s keep-alive comments |
GET /pending?after=N |
JSON {"events":[...]} polling fallback, replay by monotonic cursor |
| Variable | Effect |
|---|---|
PCSCID_HTTP_ADDR |
Bridge listen address, e.g. 127.0.0.1:8976. Empty (the default) disables bridge mode |
PCSCID_HTTP_ALLOW_REMOTE |
1 lifts the loopback guard. Without it only loopback addresses are accepted — card identities must never leave the kiosk by accident |
Worked examples against a running bridge:
$ curl http://127.0.0.1:8976/health
{"ok":true,"version":"v0.0.161"}
$ curl -N http://127.0.0.1:8976/events
event: hello
data: {"version":"v0.0.161"}
: keep-alive
event: card
data: {"id":1,"reader":"qr-xlrk-i5","card":"r3v-401-5gmr"}
$ curl 'http://127.0.0.1:8976/pending?after=0'
{"events":[{"id":1,"reader":"qr-xlrk-i5","card":"r3v-401-5gmr"}]}A kiosk page listens with a few lines of JavaScript — the permissive CORS is what allows an HTTPS page to read the loopback stream:
const events = new EventSource("http://127.0.0.1:8976/events");
events.addEventListener("card", (e) => {
const scan = JSON.parse(e.data); // {id, reader, card}
console.log(scan.reader, scan.card);
});
// Polling fallback (browsers or embedders without EventSource):
async function pending(after) {
const r = await fetch(`http://127.0.0.1:8976/pending?after=${after}`);
const {events} = await r.json();
return events; // [{id, reader, card}], oldest first
}The id cursor is monotonic: pass the last seen id as after and /pending replays only what came after it (the ring retains the last 64 presentations). Every endpoint answers with Access-Control-Allow-Origin: *; unsupported methods are rejected with 405.
A 2 s startup grace swallows Watch's initial "cards already present" report (a card left on a reader never triggers after a service restart) and the same reader+card pair is debounced for 3 s. scripts/pcscid-bridge.service is the ready-made, hardened systemd unit (After=pcscd, DynamicUser, ProtectSystem=strict, …); it ships with PCSCID_USB_PATH_ID=1 so serial-less kiosk readers get per-unit port tags, and every further PCSCID_* option (the shell export never reaches a systemd service) goes into the unit as an Environment= line. The startup settings report on stderr confirms what took effect: journalctl -u pcscid-bridge shows settings ... usb_path_id=true.
$ PCSCID_HTTP_ADDR=127.0.0.1:8976 ./pcscid
#qr-xlrk-i5:r3v-401-5gmrevents, err := pcscid.Watch(ctx, &pcscid.Options{Logger: logger})
if err != nil {
log.Fatal(err) // pcscd is a hard requirement
}
for ev := range events {
switch ev.Kind {
case pcscid.KindInsert:
// Source is "uid": an insertion event always carries a
// valid UID derived btag, unidentifiable cards are never
// reported as insertions.
fmt.Println(ev.Card.ID, ev.Card.Type, ev.Card.Source)
case pcscid.KindRemove:
fmt.Println("removed from", ev.Reader)
}
}Watch reports cards already present at startup, follows hot-plugged readers, reconnects across pcscd restarts without re-reporting still-present cards, and closes its channel when the context is cancelled.
IdentifyReaders(&opts) answers the one-shot startup inventory the sample app prints: every registered reader with its details and hashes — model tag, probed unit facts, portability tier, effective tag and the identification of a card already present. With USBPathID enabled every reader is pinned by its USB port at startup: the sysfs device scan resolves card-less readers too (one unambiguous candidate, or elimination over the already claimed ones); the hardware serial still waits for the first card presentation.
The fine-grained pieces are exported too:
pcscid.DetectType(atr) // "mifare classic 1k"
pcscid.ParseATR(atr) // full ISO 7816-3 breakdown, with TCK check
pcscid.ReaderTag(reader) // the model level xx-xxxx-xx reader tag
pcscid.ReaderTagWithUnit(r, serial, port) // + hardware serial / USB port identity
pcscid.ReaderTagWithMachine(r, serial, port, mac) // + machine identity
pcscid.MachineID() // physical ethernet MACs, "aa:…|aa:…"
pcscid.Btag(type, uid) // the xxx-xxx-xxxx btagAnd the bridge is a library piece as well — feed it from any event loop:
bridge := pcscid.NewBridge(nil) // ring buffer, dedup and grace defaults
go bridge.Serve(ctx, "127.0.0.1:8976") // or mount bridge.Handler() yourself
for ev := range events {
bridge.Feed(ev) // inserts become {reader tag, btag} events
}| Source | Cards |
|---|---|
| PC/SC part 3 ATR table | MIFARE Classic 1K/4K, Mini, Ultralight / C / EV1, MIFARE Plus SL1/SL2, FeliCa, PicoPass family, Topaz, Jewel, ICODE family, SLE55R / my-d, TAG IT, LRI family, AT88 family, Melexis sensor tag |
| Known ATRs | German eID / passport (npa), YubiKey 5 NFC, Deutschlandticket (VDV-KA) |
| Everything else | unknown type — still identified by UID when the reader provides one; the UID probe works for any ISO 14443 tag and most contactless readers |
cmd/pcscid ──▶ pcscid.Watch ──▶ pcsc.Client ──▶ /run/pcscd/pcscd.comm ──▶ pcscd ──▶ reader ──▶ card
(events, IDs) (pure Go (wire protocol 4.5 or 4.4,
client) negotiated per daemon)
pcsc.Clientperforms the header-less version handshake (claims 4.4, adopts 4.5 when pcscd 2.x offers it, down-negotiates for old daemons), establishes a context and fetches the 16-entryREADER_STATEarray — reader names, presence bits, event counters, ATRs.- Card insertions (presence bit plus event counter change) trigger identification: connect in shared mode, negotiate T=0/T=1, transmit the UID pseudo-APDU, disconnect.
- The ATR yields the card type; type plus UID yields the btag. A valid UID is mandatory: when the UID read fails after its full retry and card-reset budget, or the card serves an ISO/IEC 14443-3 random UID, the presentation is skipped — no insertion event, no output line, silently in normal mode and fully traced under
DEBUG=1. - Reader state waits are bounded at a 250 ms tick; timeouts are client-side and unblock the daemon through the stop request, so the stream stays in sync. Context cancellation closes the socket for an instant exit. With no reader registered the daemon answers the wait immediately — the loop polls gently instead of spinning.
The empirical protocol gotchas (header-less responses, the unframed APDU bytes of CMD_TRANSMIT, the registration dump that is not a change signal) are documented in the code and covered by tests against both daemon generations.
- The bridge listens on loopback only by default (
PCSCID_HTTP_ALLOW_REMOTEis the explicit, deliberate escape hatch). - Permissive CORS is a kiosk trade-off: it also means every page open in a browser on the kiosk itself can read scans — run bridge mode only on a locked-down terminal, never on a multi-user desktop.
- The btag is a truncated SHA-256 (~52 bits) — an identifier, not a secret. Treat a btag like a username, never like a password or a key.
- Response reads are fixed-size structs (the client never trusts a daemon-side length except the transmit answer, which is capped at the 264-byte short APDU buffer with an error beyond it), so a misbehaving daemon cannot turn the client into a giant allocation.
- Linux with
pcscdrunning (pcsc-lite 1.8.24+ speaks protocol 4.4/4.5 natively; older daemons are handled through down-negotiation) - Go 1.26+ to build (any Go 1.25+ toolchain auto-downloads the pinned version from
go.mod) - Socket
/run/pcscd/pcscd.comm(PCSCLITE_CSOCK_NAMEoverrides)
$ go install paepcke.de/pcscid/cmd/pcscid@latest # from the module host
$ git clone https://paepcke.de/pcscid && make build # from source, with semver baked incmd/pcscid/ sample app: bare btags, full trace with DEBUG=1, bridge with PCSCID_HTTP_ADDR
pcsc/ pure Go pcscd wire-protocol client (Linux)
internal/pcscfake in-process fake pcscd daemon driving the hardware-free tests
pcscid.go Watch loop, event model, btag and reader tag derivation
readerid.go reader unit identity: serial/channel-id probes, sysfs USB port resolution, URB traffic correlation, machine identity
bridge.go loopback HTTP bridge: SSE + polling of reader tags and btags
atr.go ISO 7816-3 answer-to-reset parser
cardtype.go ATR to card type detection (PC/SC part 3 table, known ATRs)
uid.go UID pseudo-APDU probe, random UID detection
sign.go SSHSIG line signatures (pure stdlib ssh-ed25519)
version.go semver via go linker -ldflags injection
scripts/ hardened systemd unit for bridge mode on a kiosk
$ make build # binary with the latest git tag baked in as the semver
$ make check # gofmt, go vet, go mod tidy
$ make test # full suite, fully parallel, no hardware needed
$ make push # pull, then push commits and tagsThe semver of ./pcscid -version comes from git describe --tags --abbrev=0 through
-ldflags "-X paepcke.de/pcscid.version=<tag>"; without injection the binary falls back
to the VCS revision recorded in the Go build info, and finally to the current release tag.
$ make test # go test ./... — fully parallel, no hardware neededThe fake daemon re-implements the wire protocol a second time, so client and fake agreeing is itself part of the test; it emulates all three daemon generations (4.4, 4.5, and the 4.2 downgrade). The client was additionally verified live against a real pcscd 2.4.1.