250 lines
9 KiB
Markdown
250 lines
9 KiB
Markdown
# boltcard-writer
|
||
|
||
Write, verify and wipe **Bolt Cards** (NXP NTAG 424 DNA) from a Linux PC with a
|
||
USB NFC reader, straight from the LNbits **Bolt Cards** extension's auth link.
|
||
No phone app needed.
|
||
|
||
Field-tested on Arch Linux with a **HID Omnikey 5022 CL** (write + read-back
|
||
verified 2026-09-20); the **ACS ACR1252U** is the other intended reader. Any
|
||
PC/SC (CCID) reader that supports ISO 14443-4 should work.
|
||
|
||
What it does is exactly what the official
|
||
[Bolt Card programmer app](https://github.com/boltcard/bolt-card-programmer)
|
||
does with the same link: write the `lnurlw://` URL, enable SDM mirroring
|
||
(UID + tap counter encrypted with k1, CMAC with k2), lock the file to k0, set
|
||
keys 1–4 then 0. After writing it reads the card back and checks the mirrored
|
||
`p`/`c` values with k1/k2 — the same check LNbits does on every tap.
|
||
|
||
## Setup (Arch Linux / Omarchy)
|
||
|
||
```sh
|
||
sudo pacman -S --needed pcsclite ccid uv git
|
||
sudo systemctl enable --now pcscd.socket
|
||
|
||
git clone https://git.atitlan.io/aiolabs/boltcard-writer.git
|
||
cd boltcard-writer
|
||
uv run boltcard-writer readers
|
||
```
|
||
|
||
`uv run` creates a virtualenv and installs the two Python dependencies on
|
||
first use (needs internet once).
|
||
|
||
**No uv?** (e.g. the Omarchy mirror 404s on the package, try
|
||
`sudo pacman -Syu uv` first). The only dependencies are click and
|
||
cryptography, both in Arch's repos, and the checkout ships a launcher:
|
||
|
||
```sh
|
||
sudo pacman -S --needed python python-click python-cryptography
|
||
./boltcard-writer readers # instead of `uv run boltcard-writer readers`
|
||
```
|
||
|
||
With the ACR1252U plugged in you should see two readers, the starred one is
|
||
used:
|
||
|
||
```
|
||
* ACS ACR1252 1S CL Reader PICC 00 00
|
||
ACS ACR1252 1S CL Reader SAM 00 01
|
||
```
|
||
|
||
Smoke test with any NTAG 424 DNA card (blank or not):
|
||
|
||
```sh
|
||
uv run boltcard-writer read
|
||
```
|
||
|
||
```
|
||
Tap the card on: ACS ACR1252 1S CL Reader PICC 00 00
|
||
Card:
|
||
UID: 04A1B2C3D4E5F6
|
||
chip: NTAG 424 DNA (HW 30.00, SW 01.00, storage 0x11)
|
||
ATR: 3B8180018080
|
||
key versions: k0=0 k1=0 k2=0 k3=0 k4=0
|
||
NDEF file: SDM off, comm plain, read=free write=free change=key0
|
||
NDEF URL: (empty)
|
||
state: blank / factory
|
||
```
|
||
|
||
## Writing a card
|
||
|
||
1. In LNbits open the **Bolt Cards** extension and create the card as usual
|
||
(name, limits, **card UID**: get it with `read` above, keys auto-generated).
|
||
2. Click the QR icon on the card row, then **Keys / Auth link**: this copies
|
||
`https://<your lnbits>/boltcards/api/v1/auth?a=<code>` to the clipboard.
|
||
Back up the keys from that dialog somewhere safe.
|
||
3. Put a blank card on the reader and run:
|
||
|
||
```sh
|
||
uv run boltcard-writer write 'https://<your lnbits>/boltcards/api/v1/auth?a=<code>'
|
||
```
|
||
|
||
```
|
||
Card: my card
|
||
LNURLw: lnurlw://lnbits.example.com/boltcards/api/v1/scan/9f2c…
|
||
Keys backed up to /home/you/.local/share/boltcard-writer/backups/20260920-181200-write-my_card.json
|
||
Tap the card on: ACS ACR1252 1S CL Reader PICC 00 00
|
||
UID: 04A1B2C3D4E5F6
|
||
…
|
||
state: blank / factory
|
||
Write this card now? Keep it still on the reader until done [y/N]: y
|
||
|
||
… writing NDEF URL (119 chars, p@88 c@124)
|
||
… authenticating with key 0
|
||
… enabling SDM mirroring on the NDEF file
|
||
… changing key 1
|
||
… changing key 2
|
||
… changing key 3
|
||
… changing key 4
|
||
… changing key 0
|
||
… reading back and verifying p/c
|
||
… verified: uid 04A1B2C3D4E5F6 counter 1
|
||
|
||
Done. Bolt Card 04A1B2C3D4E5F6 written and verified (counter 1).
|
||
```
|
||
|
||
**Do not move the card until "Done"** — the whole write takes about a second.
|
||
|
||
The auth link is **single-use**: LNbits rotates the code every time it is
|
||
fetched. The keys are saved to a backup file *before* the card is touched, so
|
||
if something fails mid-way you can retry with the backup instead of a new link:
|
||
|
||
```sh
|
||
uv run boltcard-writer write ~/.local/share/boltcard-writer/backups/<file>.json
|
||
```
|
||
|
||
The `boltcard://program?url=…` deep link behind the QR code is accepted too,
|
||
and `-` reads a keys JSON from stdin.
|
||
|
||
## Checking a written card
|
||
|
||
```sh
|
||
uv run boltcard-writer read --keys ~/.local/share/boltcard-writer/backups/<file>.json
|
||
```
|
||
|
||
reads the live `p`/`c` the card mirrors and verifies them with k1/k2, printing
|
||
the tap counter. Or just tap it on any Lightning POS / wallet that speaks
|
||
LNURL-withdraw.
|
||
|
||
## Wiping a card
|
||
|
||
Resets keys to zero, disables SDM and clears the NDEF so the card is blank
|
||
again (delete it in LNbits afterwards).
|
||
|
||
1. In LNbits, QR icon on the card row → **Wipe** → copy the JSON (`WIPE DATA`).
|
||
2. Save it to a file, or pipe it:
|
||
|
||
```sh
|
||
uv run boltcard-writer wipe wipe.json
|
||
# or
|
||
pbpaste | uv run boltcard-writer wipe - # wl-paste on Wayland, xclip -o on X11
|
||
```
|
||
|
||
A backup file from `write` or the card's auth link work as well. The JSON's
|
||
`uid` is checked against the card on the reader so you cannot wipe the wrong
|
||
card by mistake.
|
||
|
||
## Options
|
||
|
||
```
|
||
boltcard-writer [-r READER] [--debug] [--timeout SECS] COMMAND
|
||
|
||
readers list readers
|
||
read [--keys FILE] inspect the card, optionally verify p/c
|
||
write SOURCE [-y] [--force] [--no-verify] [--backup-dir DIR]
|
||
wipe SOURCE [-y] [--backup-dir DIR]
|
||
```
|
||
|
||
`--debug` prints every APDU exchanged with the card to stderr. **Include that
|
||
output when reporting a problem.** Key material is never in the trace (key
|
||
changes travel encrypted).
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Fix |
|
||
|---|---|
|
||
| `libpcsclite.so.1 not found` | `sudo pacman -S pcsclite` (NixOS: use `nix-shell`) |
|
||
| `SCARD_E_NO_SERVICE` | `sudo systemctl enable --now pcscd.socket` |
|
||
| `SCARD_E_NO_READERS_AVAILABLE` | reader unplugged, or the `ccid` package is missing. `lsusb` should list `072f:223b Advanced Card Systems`; `pcsc_scan` (package `pcsc-tools`) should show it |
|
||
| `SCARD_E_SHARING_VIOLATION` | close `pcsc_scan` or the other program using the reader |
|
||
| Waits forever at "Tap the card" | card not detected: put it flat on the reader's centre; a different card type (NTAG 213, credit card) is rejected on first command instead |
|
||
| `AUTHENTICATION_ERROR` / `RndA' mismatch` on write | the card is not factory-fresh; its k0 is not zero. Wipe it with the keys it was written with |
|
||
| `AUTHENTICATION_ERROR` on wipe | wrong keys for this card |
|
||
| `AUTHENTICATION_DELAY` | too many bad auths; leave the card on the reader ~1 s and retry |
|
||
| `card already looks provisioned` | it has SDM on or non-zero key versions; wipe first (`--force` only if you know k0 is zero) |
|
||
| `this is not an NTAG 424 DNA` | wrong tag; Bolt Cards need NTAG 424 DNA |
|
||
|
||
| `SCARD_W_SECURITY_VIOLATION` (0x8010006A) | polkit refused your user access to pcscd. Arch's pcsclite only auto-allows local *active* logins, so SSH sessions and some Wayland setups are denied. Grant it explicitly (below) |
|
||
|
||
```sh
|
||
sudo tee /etc/polkit-1/rules.d/50-pcscd.rules >/dev/null <<'EOF'
|
||
polkit.addRule(function(action, subject) {
|
||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||
action.id == "org.debian.pcsc-lite.access_card") &&
|
||
subject.isInGroup("wheel")) {
|
||
return polkit.Result.YES;
|
||
}
|
||
});
|
||
EOF
|
||
```
|
||
|
||
Takes effect immediately, no restart.
|
||
|
||
## Nix / NixOS
|
||
|
||
The repo is a flake. Run it without installing anything:
|
||
|
||
```sh
|
||
nix run git+https://git.atitlan.io/aiolabs/boltcard-writer -- readers
|
||
nix run git+https://git.atitlan.io/aiolabs/boltcard-writer -- write '<auth link>'
|
||
```
|
||
|
||
pcscd must be running on the host (`services.pcscd.enable = true;` on NixOS,
|
||
which also ships the CCID driver). On NixOS the module does that for you:
|
||
|
||
```nix
|
||
{
|
||
inputs.boltcard-writer.url = "git+https://git.atitlan.io/aiolabs/boltcard-writer";
|
||
# in your configuration:
|
||
imports = [ inputs.boltcard-writer.nixosModules.default ];
|
||
programs.boltcard-writer.enable = true; # installs the CLI + pcsc_scan, enables pcscd
|
||
}
|
||
```
|
||
|
||
`packages.default` / `overlays.default` expose the package on its own. The
|
||
package build runs the test suite, so `nix flake check` doubles as CI. For
|
||
hacking, `nix develop` (or `nix-shell`) gives `uv`, Python and `libpcsclite`;
|
||
then `uv run boltcard-writer …` and `uv run pytest` work as on Arch.
|
||
|
||
## Development
|
||
|
||
```sh
|
||
uv run pytest # AN12196 vectors + a software NTAG 424 simulator
|
||
uv run ruff check
|
||
```
|
||
|
||
No hardware is needed for the tests. `tests/test_vectors.py` replays the
|
||
worked examples from NXP application note AN12196; `tests/simcard.py` is a
|
||
minimal software card that verifies MACs, decrypts, changes keys and renders
|
||
SDM mirroring, so the write/wipe flows run end to end.
|
||
|
||
## How it maps to the card
|
||
|
||
| Step | Command | Comm mode |
|
||
|---|---|---|
|
||
| NDEF URL with `p=`/`c=` placeholders | ISOSelectFile + ISOUpdateBinary | plain (factory file is free-write) |
|
||
| Session | AuthenticateEV2First key 0 | AES, LRP not used |
|
||
| SDM on, offsets, write locked to k0 | ChangeFileSettings file 02 | full (`40 00E0 C1 FF12 …`) |
|
||
| k1..k4 then k0, key version 1 | ChangeKey | full |
|
||
| Verify | ISOReadBinary, decrypt `p` with k1, CMAC with k2 | plain |
|
||
|
||
LNbits stores `k3 = k1` and `k4 = k2`, as the phone app does. Random-UID mode
|
||
(`uid_privacy`) is not used by LNbits and not implemented.
|
||
|
||
## Limitations
|
||
|
||
- Linux only (ctypes bindings to `libpcsclite`; no compiled extension).
|
||
- Assumes factory keys (all zero) when writing; a card written with other
|
||
keys must be wiped with them first.
|
||
|
||
## License
|
||
|
||
MIT
|