pcscid

package module
v0.0.165 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: MIT Imports: 23 Imported by: 0

README

pcscid

One short, stable ID for every smart card you tap. Any card, any reader, pure Go.

go version cgo dependencies platform license

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 line

Why pcscid

Every 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. pcscid reads the card's own anti-collision UID through the PC/SC part 3 GET DATA APDU (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 test needs 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.

The btag

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.

Reader identity: model, unit, machine

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 ./pcscid

The 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.

Quick start

$ make build
$ ./pcscid
#qr-xlrk-i5:r3v-401-5gmr

One 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=uid

A 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-5gmr

When 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).

Signed output lines

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.

Bridge mode: loopback HTTP for browser pages

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-5gmr

Use the library

events, 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 btag

And 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
}

Card types recognized

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

How it works

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)
  1. pcsc.Client performs 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-entry READER_STATE array — reader names, presence bits, event counters, ATRs.
  2. 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.
  3. 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.
  4. 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.

Security posture

  • The bridge listens on loopback only by default (PCSCID_HTTP_ALLOW_REMOTE is 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.

Requirements & install

  • Linux with pcscd running (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_NAME overrides)
$ 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 in

Project layout

cmd/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

Development

$ 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 tags

The 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.

Testing

$ make test    # go test ./... — fully parallel, no hardware needed

The 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.

License

MIT

Documentation

Overview

Package pcscid identifies any smart card presented to a reader registered with the local pcscd: NFC, mifare, RFID, contact smart cards and everything else the daemon manages.

It is pure Go without cgo, on Unix it speaks the pcscd daemon wire protocol directly. The identifier of a card, its btag, is a short hash over the card type and the unique tag of the individual card, its UID read through the PC/SC part 3 GET DATA APDU. A btag always requires a valid UID: when no UID can be read (a failed probe or an ISO/IEC 14443-3 random UID that identifies nothing) the card presentation carries no btag and no insertion event is reported — silently in normal operation, with the full diagnostic trace under a debug level logger.

Reader unit identity: the per-unit discriminator of a reader.

The reader name alone identifies the model, not the unit, so the watch loop probes the driver for two per-unit facts while a card is connected, in one connection:

  • SCARD_ATTR_VENDOR_IFD_SERIAL_NO, the USB iSerial string burned into the reader hardware. Portable: the same unit carries it on every machine, port and daemon restart. Some reader families, for example the ACS ACR122U, ship a constant placeholder ("0" on every unit), which is filtered here.
  • SCARD_ATTR_CHANNEL_ID, the USB bus and device address, resolved through sysfs to the kernel physical port path (devpath, "2-1.3"). The port path anchors the unit to the port it sits on: stable across daemon restarts and reboots, but not portable, moving the reader to another port changes it. It is the opt-in fallback (Options.USBPathID, PCSCID_USB_PATH_ID in the sample app) when no usable serial exists.

When the driver serves neither usable serial nor channel id, the sysfs USB tree is scanned for the reader's device directly: the USB manufacturer and product strings must match the pcscd reader name (pcscd derives that name from the same vendor and product strings) and the device must carry a CCID interface. Exactly one matching device identifies the port, two identical models cannot be told apart without the channel id. With USBPathID enabled an unresolved port is reported as a qualified error, not swallowed silently: the reader then keeps the model level tag, which two identical units share.

MachineID (readerid.go bottom) is the machine level identity, the stable hardware MAC addresses of the physical ethernet ports, mixed into the reader tag when enabled (Options.MACID, PCSCID_MAC_ID in the sample app).

Reader inventory: the startup evaluation of every reader currently registered with the local pcscd.

IdentifyReaders answers the operator question "what exactly is attached here and which tags will it serve" before the first card is presented: it enumerates the daemon's reader state array and derives every identity tier of each reader — the model level tag from the reader name, the per-unit facts (hardware serial, physical USB port path) when a card is present to probe them with, and the effective tag under the configured Options, including the machine component of Options.MACID.

The per-unit facts need an open card connection (SCardGetAttrib talks to the driver through the card), so a reader reported without a card carries the model level tag only; its Tier says "model". The same limitation applies to Watch's insert events, the report just makes it visible at startup instead of after the first scan.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Btag added in v0.0.104

func Btag(cardType string, tag []byte) string

Btag derives the btag, the short unique identifier of a card, from its type and the unique tag of the individual card, its UID. The btag is 10 characters from the digits and lower case letters in three dash separated groups, xxx-xxx-xxxx, stable across readers and re-presentations, and different for two cards of the same type with different tags. It always requires a valid UID: a card whose UID cannot be read is never served a btag derived from its ATR, which would only identify the card type, not the individual card.

func DetectType

func DetectType(atr []byte) string

DetectType returns a card type name for an answer to reset. The name distinguishes card families and models, the unique identifier of an individual card comes from its UID, see Identify.

func MachineID added in v0.0.141

func MachineID() string

MachineID returns the stable hardware identity of this machine: the MAC addresses of its physical ethernet network interfaces, sorted, joined with "|" (for example "aa:bb:cc:dd:ee:01|aa:bb:cc:dd:ee:02"), or the empty string when the machine has no such interface. It reads the network stack through sysfs, pure Go: an interface counts as a stable hardware ethernet port when it is of type ethernet (ARPHRD_ETHER), is backed by a physical device (the sysfs device symlink, absent on loopback, bridges, bonds, vlans, veth and every other virtual interface), carries no wireless phy (the phy80211 entry, present on every wifi interface) and has a real, non zero MAC burned in. MACs of different physical ports never repeat on one machine, and the MAC is the most stable identifier commodity hardware carries, it survives reboots, OS reinstalls and software reconfiguration.

func ReaderTag added in v0.0.122

func ReaderTag(reader string) string

ReaderTag derives the stable short unique identifier of a reader from its pcscd reader name. The tag is 8 characters from the digits and lower case letters in three dash separated groups, xx-xxxx-xx, the same for the same reader across restarts, sockets, machines and USB ports.

pcscd reader names end in volatile hotplug indices, for example "ACS ACR122U 01 00 00", where the trailing number groups change with the USB port, the boot order and the machine. Those groups are stripped before hashing, so the tag follows the reader hardware, not its point of attachment. Two identical reader models whose names carry no serial number share one tag, ReaderTagWithSerial fixes that with the serial from the driver when one is available.

func ReaderTagWithMachine added in v0.0.141

func ReaderTagWithMachine(reader, serial, port, machine string) string

ReaderTagWithMachine adds the machine identity to the reader tag: the machine component (MachineID or an explicit override, wired by Options.MACID / PCSCID_MAC_ID) is mixed into every derivation when it is not empty, so identical readers on different machines serve distinct tags. The price is portability: a tag derived with a machine component does not follow the reader to another machine. The hash domain is pcscid/reader/m1 with the unit fact kind prefixed ("serial:...", "port:...", or "model"), so a serial and a port path of the same text can never collide. An empty machine falls back to ReaderTagWithUnit.

func ReaderTagWithSerial added in v0.0.137

func ReaderTagWithSerial(reader, serial string) string

ReaderTagWithSerial derives the reader tag from the reader name and the hardware serial number of the unit (Event.ReaderSerial, the USB iSerial string from SCARD_ATTR_VENDOR_IFD_SERIAL_NO). Readers of the same model, whose names hash to the same ReaderTag, get distinct tags, one per physical unit, still stable across machines, sockets, USB ports and daemon restarts: the serial is burned into the reader hardware. An empty serial falls back to the plain name based tag.

func ReaderTagWithUnit added in v0.0.139

func ReaderTagWithUnit(reader, serial, port string) string

ReaderTagWithUnit derives the reader tag from every per-unit fact the driver could serve, in decreasing portability:

  • a usable hardware serial (ReaderTagWithSerial semantics, hash domain pcscid/reader/v2): one tag per unit, portable across machines and USB ports.
  • the kernel physical USB port path (Event.ReaderPort, hash domain pcscid/reader/v3): one tag per unit as long as it stays in its port, stable across daemon restarts and reboots, but moving the reader or re-plugging it into another port changes the tag.
  • neither: the model level ReaderTag.

func RequireLoopback added in v0.0.127

func RequireLoopback(addr string, allowRemote bool) error

RequireLoopback guards the default deployment posture of a Bridge: card identities must never leave the kiosk. Only a loopback address (or the literal localhost) is accepted without the explicit allowRemote escape hatch.

func Version

func Version() string

Version returns the semantic version of the pcscid library or binary. The value is injected at build time via the go linker (-ldflags "-X paepcke.de/pcscid.version=<semver>"). When not injected, for plain go builds, it falls back to the VCS revision recorded in the Go build info, and finally to defaultVersion, the current release tag.

func Watch

func Watch(ctx context.Context, opts *Options) (<-chan Event, error)

Watch starts watching all readers of the local pcscd and reports card insertions and removals on the returned channel. Cards that are already present when Watch starts are reported as inserted.

When the pcscd socket cannot be reached, Watch fails with that error, the pcscd service is a hard requirement.

The channel is closed once ctx is cancelled.

Types

type ATR

type ATR struct {
	// TS is the initial character, 0x3B direct or 0x3F inverse
	// convention.
	TS byte
	// T0 carries the presence mask of the first interface byte group
	// in its high nibble and the number of historical bytes in its
	// low nibble.
	T0 byte
	// InterfaceBytes lists the TA, TB, TC and TD bytes of each
	// interface byte group in order of appearance.
	InterfaceBytes [][]byte
	// Historical is the historical byte sequence.
	Historical []byte
	// Protocols are the offered transmission protocols, starting with
	// the default T=0, deduplicated in order of first appearance.
	Protocols []byte
	// HasTCK reports whether a checksum byte is present, which is the
	// case as soon as any protocol other than T=0 is offered.
	HasTCK bool
	// TCK is the checksum byte, XOR of all bytes after TS.
	TCK byte
}

ATR is a parsed answer to reset as defined by ISO/IEC 7816-3.

func ParseATR

func ParseATR(b []byte) (ATR, error)

ParseATR parses the answer to reset of a card. It tolerates the slightly non-conformant pseudo ATRs some contactless readers emit.

func (ATR) ChecksumOK

func (a ATR) ChecksumOK(atr []byte) bool

ChecksumOK reports whether the TCK matches the XOR of all bytes after TS and before the TCK. It is true when the ATR carries no checksum. The atr argument is the raw ATR the receiver was parsed from.

type Bridge added in v0.0.127

type Bridge struct {
	// contains filtered or unexported fields
}

Bridge feeds Watch events through the tag derivation and the dedup filter and serves the result as SSE and polling JSON over HTTP. The zero value is not usable, use NewBridge.

func NewBridge added in v0.0.127

func NewBridge(opts *BridgeOptions) *Bridge

NewBridge returns a Bridge recording presentations with the given options.

func (*Bridge) Feed added in v0.0.127

func (b *Bridge) Feed(ev Event)

Feed applies one Watch event. Insertions with an identified card become one bridge presentation, {reader tag, card btag, line signature}, filtered by the dedup guard; removals and card-less events are ignored — one presentation per scan. The reader tag is Event.ReaderTag as derived by Watch (unit facts and machine identity per the Watch Options); events without one, for example from a hand built event loop, fall back to the local derivation. Feed never blocks.

func (*Bridge) Handler added in v0.0.127

func (b *Bridge) Handler() http.Handler

Handler returns the HTTP surface of the bridge: the liveness probe, the SSE stream and the polling fallback, all with permissive CORS headers so a kiosk page served from an HTTPS origin may read them from a loopback http origin.

func (*Bridge) Serve added in v0.0.127

func (b *Bridge) Serve(ctx context.Context, addr string) error

Serve listens on addr and serves the bridge HTTP surface until ctx is cancelled (graceful shutdown, a nil return) or the listener fails. RequireLoopback should be consulted first, Serve itself does not guard the address.

func (*Bridge) ServeListener added in v0.0.128

func (b *Bridge) ServeListener(ctx context.Context, ln net.Listener) error

ServeListener serves the bridge HTTP surface on an already bound listener until ctx is cancelled (graceful shutdown, a nil return) or the listener fails. Binding the listener in the caller makes a bad address fail fast instead of asynchronously.

type BridgeEvent added in v0.0.127

type BridgeEvent struct {
	ID     int64  `json:"id"`
	Reader string `json:"reader"`
	Card   string `json:"card"`
	Sig    string `json:"sig,omitempty"`
}

BridgeEvent is one card presentation served over the bridge HTTP surface. Reader is the reader tag (xx-xxxx-xx), Card the card btag (xxx-xxx-xxxx). ID is the monotonic cursor the /pending polling fallback replays with (after=last seen ID). Sig is the base64 SSHSIG line signature of the exact output line "#<Reader>:<Card>" when the bridge runs with a Signer (BridgeOptions.Signer, the PCSCID_SIGN_KEY of the sample app): byte-identical to the signature the binary prints after the '$' separator on stdout, so a consumer verifying the bridge event (chrony's POST /terminal/punch, for example) verifies the same bytes ssh-keygen -Y verify would check against the key's public half under the namespace "pcscid". Without a Signer the field is omitted.

type BridgeOptions added in v0.0.127

type BridgeOptions struct {
	// Capacity is how many presentations the /pending ring retains,
	// default 64.
	Capacity int
	// DedupWindow is how long the same reader+card pair is suppressed
	// after one presentation, default 3s.
	DedupWindow time.Duration
	// StartupGrace is how long every event is suppressed after
	// NewBridge, default 2s. It must cover the initial "already
	// present" reports of Watch.
	StartupGrace time.Duration
	// Signer, when set, signs every served presentation: each BridgeEvent
	// carries the base64 SSHSIG signature of its exact output line
	// "#<reader>:<card>" in Sig. The sample app wires the PCSCID_SIGN_KEY
	// signer here so the bridge delivers the same signature the stdout
	// lines carry — a kiosk page can then forward the event verbatim to a
	// consumer that enforces signed punches. Without a signer the events
	// stay unsigned (Sig omitted).
	Signer *Signer
}

BridgeOptions tunes NewBridge. A nil *BridgeOptions selects every default.

type Card

type Card struct {
	// ID is the btag, the short unique identifier, the output of Btag.
	ID string
	// Type is the detected card type name, see DetectType.
	Type string
	// UID is the unique tag of the individual card, nil when it
	// could not be read.
	UID []byte
	// ATR is the answer to reset of the card.
	ATR []byte
	// Reader is the reader name the card was seen on. The same card
	// reports the same ID on every reader.
	Reader string
	// Source is what the ID was derived from: "uid" when the card
	// served its unique tag, "none" when it did not (a failed UID
	// read or an ISO/IEC 14443-3 random UID that identifies nothing).
	// "none" means ID is empty: no btag is served without a valid
	// UID, the presentation is skipped instead of degraded to an ATR
	// derived type level tag.
	Source string
}

Card is the identification of one presented smart card.

type Event

type Event struct {
	// Kind is what happened.
	Kind Kind
	// Card is set for KindInsert.
	Card *Card
	// Reader is the reader name the event belongs to.
	Reader string
	// ReaderTag is the reader tag as derived by Watch from every
	// identity source the Options enabled: the reader name, the unit
	// facts (ReaderSerial, ReaderPort) and the machine identity
	// (Options.MACID). It is set for KindInsert events, empty for
	// KindRemove, which carries no card connection to probe facts
	// with. It equals ReaderTagWithUnit(ev.Reader, ev.ReaderSerial,
	// ev.ReaderPort) unless MACID mixed the machine in.
	ReaderTag string
	// ReaderSerial is the hardware serial number of the reader
	// (SCARD_ATTR_VENDOR_IFD_SERIAL_NO, the USB iSerial string of the
	// unit) when the driver serves a usable one, empty otherwise.
	// Constant vendor placeholders, for example the all zero serial of
	// the ACS ACR122U family, are filtered and also answer empty. It is
	// probed with the card connection of an insertion, so only
	// insertions carry it. See ReaderTagWithUnit.
	ReaderSerial string
	// ReaderPort is the kernel physical USB port path of the reader
	// (sysfs devpath, for example "2-1.3"), resolved through its
	// SCARD_ATTR_CHANNEL_ID bus/device address when the driver serves
	// no usable serial and Options.USBPathID allows it. It anchors the
	// unit to its port: stable across daemon restarts and reboots, but
	// moving the reader to another port changes it. Only insertions
	// carry it.
	ReaderPort string
}

Event is one reader state change.

type Kind

type Kind uint8

Kind describes the reader event that produced an Event.

const (
	// KindInsert reports a card that became present on a reader.
	KindInsert Kind = iota
	// KindRemove reports a card that is no longer present.
	KindRemove
)

func (Kind) String

func (k Kind) String() string

String implements fmt.Stringer.

type Options

type Options struct {
	// Logger receives the verbose debug trace. In normal operation a
	// discarded logger keeps the library silent. The cmd/pcscid
	// sample app wires DEBUG=1 to it.
	Logger *slog.Logger
	// SocketPath overrides the pcscd socket path. Empty selects the
	// platform default, PCSCLITE_CSOCK_NAME overrides it on Unix.
	SocketPath string
	// SysfsUSBRoot overrides the sysfs root scanned to resolve a
	// reader's USB bus/device address to its physical port path,
	// default /sys/bus/usb/devices. It exists to test the port based
	// reader identity fallback against a fake device tree.
	SysfsUSBRoot string
	// USBPathID allows the physical USB port path as the per-unit
	// fallback for readers serving no usable hardware serial (the ACS
	// ACR122U family). Off by default, because the port path is stable
	// only as long as the reader stays in its port: moving the reader
	// to another port or machine changes the tag. The sample app wires
	// PCSCID_USB_PATH_ID to it.
	USBPathID bool
	// MACID mixes the machine identity, the stable hardware MAC
	// addresses of the physical ethernet ports (MachineID), into every
	// reader tag: identical readers on different machines get distinct
	// tags, at the price of the tag no longer following the reader to
	// another machine. Off by default. The sample app wires
	// PCSCID_MAC_ID to it.
	MACID bool
	// MachineID overrides the machine identity used when MACID is
	// enabled. Empty uses MachineID(), which is empty again on machines
	// without a physical ethernet port; those then keep the unit level
	// tags. It exists as the injection point of a caller chosen
	// machine identity and for tests.
	MachineID string
}

Options tunes Watch. A nil Options selects every default.

type ReaderInfo added in v0.0.148

type ReaderInfo struct {
	// Reader is the pcscd reader name, for example
	// "ACS ACR122U 00 00".
	Reader string
	// ModelTag is the model level tag (ReaderTag): derived from the
	// normalized reader name alone, identical for two units of the
	// same model.
	ModelTag string
	// Serial is the hardware serial number of the unit
	// (SCARD_ATTR_VENDOR_IFD_SERIAL_NO) when the driver serves a
	// usable one, empty otherwise. It is only probeable while a
	// card is present.
	Serial string
	// Port is the kernel physical USB port path of the unit
	// (sysfs devpath, for example "2-1.3") when Options.USBPathID
	// allows it and the reader serves no usable serial. It is only
	// probeable while a card is present.
	Port string
	// Tier names the portability tier of the per-unit facts that
	// produced Tag: "serial" (one tag per unit, portable), "port"
	// (one tag per USB port, not portable) or "model" (no per-unit
	// fact readable, name only).
	Tier string
	// Tag is the effective reader tag under the configured Options:
	// ReaderTagWithMachine over the probed facts, the tag every
	// insertion event of this reader will carry in Event.ReaderTag.
	Tag string
	// CardPresent reports whether a card sits on the reader.
	CardPresent bool
	// Card is the identification of the presented card, the same
	// derivation an insertion event carries, nil when CardPresent
	// is false. A card whose UID could not be read carries Source
	// "none" and an empty ID (no btag without a valid UID), only
	// the type and ATR facts are reported.
	Card *Card
}

ReaderInfo is the evaluated identity of one reader registered with the local pcscd, one entry of the IdentifyReaders inventory.

func IdentifyReaders added in v0.0.148

func IdentifyReaders(opts *Options) ([]ReaderInfo, error)

IdentifyReaders evaluates every reader currently registered with the local pcscd and answers the inventory of their identities: the reader name, every derivable tag and the per-unit facts behind it, plus the identification of a card that is already present. The evaluation uses the same options and derivations as Watch, so the reported Tag is exactly the tag the reader's insertion events will carry. Readers with a presented card are probed over a full card connection (serial, port, card UID). Readers without a card cannot open the connection the driver attributes need, but with USBPathID enabled their USB port is still pinned from the sysfs device scan when it is unambiguous (or every other identical unit is already owned by its own probe); they report the model level tier otherwise.

When the pcscd socket cannot be reached, IdentifyReaders fails with that error, the pcscd service is a hard requirement.

type Signer added in v0.0.132

type Signer struct {
	// contains filtered or unexported fields
}

Signer signs output lines with an ed25519 private key in the OpenSSH SSHSIG format.

func NewSigner added in v0.0.132

func NewSigner(path string) (*Signer, error)

NewSigner loads an OpenSSH ed25519 private key from path and returns a Signer over it. The key must be usable for signing: an unencrypted ("openssh-key-v1" container, cipher "none") ed25519 key whose private half matches its public half. Encrypted keys, keys of other types and damaged files fail with an error instead of silently producing unsigned output.

func (*Signer) Sign added in v0.0.132

func (s *Signer) Sign(msg []byte) string

Sign returns the SSHSIG signature of msg as base64. The SSHSIG blob is the binary signature format inside the armored block of `ssh-keygen -Y sign`; encoding it as plain base64 keeps one signed output line a single line, it re-arms into the PEM style block by wrapping it at 70 columns between the armor lines.

func (*Signer) SignLine added in v0.0.132

func (s *Signer) SignLine(line string) string

SignLine returns line with the separator and the base64 encoded SSHSIG signature of the line itself appended: line$<base64>. The signature covers exactly the bytes of line as emitted, so a signature over "#xx-xxxx-xx:xxx-xxx-xxxx" is stable text a verifier can re-sign and compare.

Directories

Path Synopsis
cmd
pcscid command
Command pcscid prints a short unique identifier for every smart card presented to any reader registered with the local pcscd.
Command pcscid prints a short unique identifier for every smart card presented to any reader registered with the local pcscd.
internal
pcscfake
Package pcscfake implements an in-process fake pcscd daemon.
Package pcscfake implements an in-process fake pcscd daemon.
Package pcsc implements a pure Go client for the pcscd IPC protocol on Linux.
Package pcsc implements a pure Go client for the pcscd IPC protocol on Linux.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL