keycard-nostr-bunker
A NIP-46 remote signer (bunker) that keeps your Nostr key on a Keycard.
With a Keycard running applet 4.x, every signature and every ECDH shared secret is computed on the card. The private key never leaves the secure element. Any NIP-46 client (Coracle, noStrudel, Snort, Amethyst) connects to the bunker with a bunker:// URL.
Nostr client ──NIP-46──▶ relay ──NIP-46──▶ keycard-nostr-bunker ──PC/SC──▶ Keycard
- The bunker subscribes to the configured relays for kind:24133 events addressed to its bunker key.
- Each request is checked (signature, age), decrypted with NIP-44 (or NIP-04 for older clients) and dispatched.
sign_eventsends the event hash to the card, which returns a BIP-340 Schnorr signature.nip44_*andnip04_*ask the card for the ECDH shared secret with the peer and do the symmetric part on the host.- The reply is encrypted and published back to the relays.
Three keys
- Your identity is the key on the card at
key_path. The bunker never has it. It learns the public key from the card and remembers it inuser.pubkey, together with the card's key UID, so another card is refused before any PIN is sent. - The bunker key in
signer.keyis a software key used only for the NIP-46 transport. Clients address it; it signs replies. It never signs anything as you. - A client is whatever keypair the Nostr app generated for itself. The bunker knows it only as a pubkey and admits it by the secret in the bunker URL, see below.
Requirements
- Python 3.11 or later
- A Keycard development card with applet 4.0.1 or later. Keycards sold in stores still ship applet 3.x, which has no Schnorr and no ECDH, so the bunker refuses them. Get a development card.
- A PC/SC reader and
pcscdrunning:sudo systemctl enable --now pcscd.socket - To build
pyscard, which has no Linux wheels on PyPI: a C compiler,swig, Python headers and pcsc-lite headers. Fedora:sudo dnf install gcc swig python3-devel pcsc-lite-devel. Debian and Ubuntu:sudo apt install build-essential swig python3-dev libpcsclite-dev.
Advertisement: the development card link above is an affiliate link. The developer earns a commission on purchases made through it. Nothing in keycard-nostr-bunker depends on buying through it.
Installation
pipx install keycard-nostr-bunker
Or from source:
git clone https://github.com/mmlado/keycard-nostr-bunker
cd keycard-nostr-bunker
python -m venv .venv && source .venv/bin/activate
pip install .
Keycard setup
A development card needs the 4.x applet and an identity certificate first:
- Build and install the applet from keycard-tech/status-keycard at tag
4.0.1:./gradlew convertJavacard, then./gradlew installwith only the card's reader connected. This needs OpenJDK 11, and works as is because development cards come with the Keycard development keys. - Load an identity certificate from the Test CA:
keycard-cli load-ident --test, orpython tools/load_test_identity.pyfrom a keycard-py checkout. - Set
trust_test_ca = truein the config.
Then the card needs a PIN and a key. With keycard-py (pip install keycard mnemonic):
from keycard import KeyCard, constants
from keycard.constants import LoadKeyType
from mnemonic import Mnemonic
# A development card's certificate comes from the Test CA, so trust it here too.
card = KeyCard(ca_public_keys=[constants.OFFICIAL_CA_PUBLIC_KEY, constants.TEST_CA_PUBLIC_KEY])
card.transport.connect()
card.select()
card.init('123456', '987654321098') # PIN, PUK; opens the secure channel
card.verify_pin('123456')
card.generate_key() # a fresh key that lives only on the card
# or, to keep an identity you already have as a mnemonic:
# card.load_key(LoadKeyType.BIP39_SEED, bip39_seed=Mnemonic.to_seed('your twelve words'))
keycard-cli does the same with keycard init and keycard shell.
Can I keep my npub? Only if your identity exists as a mnemonic (NIP-06) and you load that seed: with key_path = "m/44'/1237'/0'/0/0" the card derives the same key as any NIP-06 wallet. A bare nsec cannot be loaded as a derived key, so an nsec-only identity means a new npub.
Certificates. Applet 4.x cards prove they are genuine with an Identity Certificate. Store-bought 4.x cards, once they exist, will carry one from the official CA, trusted by default. A development card carries one from the Test CA (trust_test_ca = true) or from your own CA (ca_public_keys). Trust the Test CA only on machines you control, since anyone can sign with it.
Configuration
The first keycard-nostr-bunker run writes the config to ~/.config/keycard-nostr-bunker/config.toml. In a terminal it asks a few questions first: the relays, what the card may sign, which certificate your card carries, whether to ask before admitting a client, and whether to store the PIN for running as a service. Enter keeps the suggested answer. Without a terminal it writes the defaults and says where. keycard-nostr-bunker init asks again later; it starts from the current values, keeps every setting it does not ask about, and backs up the old file first.
The file is commented, and the rest is set by editing it. The loader is strict: an unknown key or a value of the wrong type is an error, and a config that holds a PIN or secret must be mode 0600.
| Section | Key | Meaning |
|---|---|---|
relay |
urls |
Relays to listen on and publish replies to; two is a good number |
keycard |
pin |
Card PIN; see the PIN sources below |
reader |
Part of the PC/SC reader name to use; empty means any | |
key_path |
Derivation path of the Nostr key, under m/44'/1237' or m/43'/60'/1581' |
|
trust_test_ca, ca_public_keys |
Extra Keycard CAs to trust | |
signer |
key_file |
Bunker keypair file; the other state files live next to it |
signing |
policy |
allowed_kinds signs only the listed kinds; all signs everything |
allowed_kinds |
Event kinds to sign; the example config lists the usual ones | |
connection |
secret |
Secret in the bunker URL that admits a client; generated if empty |
rotate_secret |
Replace the secret once a client has used it, so a URL serves one client; on unless secret is set |
|
approve_clients |
Ask before admitting a client | |
authorized_clients |
Client pubkeys admitted without the secret |
The key path decides the public key. The default is the EIP-1581 subtree, which the Nim predecessor used, so existing users keep their npub. m/44'/1237'/0'/0/0 gives the same npub as any NIP-06 wallet seeded from the same mnemonic.
State files
Everything the bunker remembers sits next to signer.key, mode 0600 in a 0700 directory. The bunker creates that directory itself; if it already exists and other users can enter it, the bunker says so at start and leaves it as it is, since it may be a directory you share.
| File | Holds |
|---|---|
signer.key |
the bunker's transport key |
secret |
the generated admission secret; clients revoke and rotate_secret replace it |
clients |
admitted client pubkeys, with when and through which relay they came |
pending |
clients waiting for approval, with the permissions they asked for |
admission.lock |
lets the bunker and the clients commands take turns changing clients, pending and secret |
seen |
ids of recent requests from known clients, so a restart does not serve them twice |
user.pubkey |
your public key and the card's key UID |
pin.rejected |
present after a wrong PIN; removed by a successful one |
Running
keycard-nostr-bunker
That reads config.toml from the current directory, or ~/.config/keycard-nostr-bunker/config.toml if there is none. -c FILE picks another; it goes before or after the subcommand.
11:02:04 [INFO] Config: /home/me/.config/keycard-nostr-bunker/config.toml
11:02:04 [INFO] State: /home/me/.config/keycard-nostr-bunker
11:02:04 [INFO] Bunker pubkey: 4f6915de49dbfe3457bcedaf217a25ac188222f96e1a17aa518eb57e5ccf0c89
11:02:04 [INFO] Keycard: applet 4.0 in Generic USB2.0-CRW [Smart Card Reader Interface] 00 00
11:02:05 [INFO] Keycard: PIN verified
11:02:05 [INFO] Keycard: ready
11:02:05 [INFO] User pubkey: 1ed7d46f37d4f0f5f35538679bf77fd9b0a839041acf3a1dbbae5a3e0fd347e1
11:02:05 [INFO] Signing policy: allowed_kinds [0, 1, 3, 4, 5, 6, 7, 13, 10002, 10050, 22242]
11:02:05 [INFO] Admitted clients: 0
bunker://4f6915de49dbfe3457bcedaf217a25ac188222f96e1a17aa518eb57e5ccf0c89?relay=wss://nos.lol&relay=wss://relay.primal.net&secret=X0dxuE2zI2BAOcGKqWTbsg
11:02:05 [INFO] Connected to relay: wss://nos.lol
11:02:10 [INFO] client 8f1f3a2c9e0d4b7a61c5e2f80b93d4a17c6e58f2a0b1d3c4e5f60718293a4b5c admitted, requested: nothing listed
11:02:10 [INFO] NIP-46 connect -> ok
11:02:11 [INFO] NIP-46 sign_event kind 1 -> ok
Paste the bunker:// URL into the client's remote signer field. The URL carries the secret, so it is printed only when the bunker runs in a terminal; a service logs a hint instead, and keycard-nostr-bunker url prints it on demand.
| Command | |
|---|---|
keycard-nostr-bunker or keycard-nostr-bunker run |
start the bunker; the first run writes the config |
keycard-nostr-bunker init |
set up the config by answering a few questions |
keycard-nostr-bunker run --no-card |
sign with a throwaway key made at start instead of a card, to test the relay side |
keycard-nostr-bunker url |
print the bunker URL, secret included, without starting anything |
keycard-nostr-bunker readers |
list PC/SC readers, for the reader setting |
keycard-nostr-bunker service install / uninstall |
systemd user service, see below |
keycard-nostr-bunker clients list / pending |
admitted clients, clients waiting for approval |
keycard-nostr-bunker clients approve <pubkey> / revoke <pubkey> |
admit or remove a client |
-v turns on debug logging.
Admitting clients
Clients are admitted by the secret in the bunker:// URL: the first connect with it enrolls the client's pubkey in clients, and every other request from a pubkey not in that file is refused with unauthorized. The URL is therefore a bearer credential: whoever has it can enroll a client. Treat it like a password and paste it only into apps you trust.
The secret rotates by default: as soon as a client presents it, the bunker replaces it, whether or not you then approve that client, and prints the new URL in a terminal. A URL you already used admits nobody else, and if someone else uses yours first, your client gets invalid secret and you know. Admitted clients reconnect without the secret, so rotation only matters when you add one: run keycard-nostr-bunker url for the current URL each time. rotate_secret = false keeps one URL for any number of clients, until a clients revoke. A secret fixed in the config with connection.secret does not rotate.
keycard-nostr-bunker clients revoke <pubkey> removes a client, drops its request from pending if it has one, and rotates the secret at the same time, since the client still knows the old one; the running bunker picks both changes up at once. If the secret is fixed in the config, change it there. The same command is the answer to a leaked URL: revoke anything you do not recognise, and the old URL admits nobody.
approve_clients = true tightens admission further. It makes the bunker ask you before admitting. In a terminal it prompts, with a minute to answer, and the answer is the first four characters of the pubkey shown, so a stray keystroke cannot approve anyone. As a service it parks the client in pending, logs the pubkey, and the client gets pending approval until you run keycard-nostr-bunker clients approve <pubkey> and it connects again. clients pending lists who is waiting, with the time, the relay and the permissions asked for. Only a client that asked can be approved: clients approve refuses a key that is not in pending. A waiting client that connects again gets pending approval without being asked for the secret again, which matters with rotate_secret, since the secret in its URL is already gone. pending keeps the 64 most recent requests; a client pushed out has to connect again with a current URL. Approve only a client you have just set up yourself, right after pasting the URL, and refuse anything you did not expect. A hacker with the URL then still needs your approval, on your machine.
To admit a client whose pubkey you already know, without any URL, list it in connection.authorized_clients. Those clients skip the secret and the approval and show up in clients list as coming from the config. The list is read when the bunker starts. To revoke one, run clients revoke <pubkey>, which also removes any record the key got at runtime, then delete it from the config and restart the bunker.
For a desktop, approve_clients with the prompt is the comfortable choice. For a headless service, the rotating secret keeps a used URL harmless and clients approve over SSH works when you need it.
A client can also say what it needs. NIP-46 lets connect carry a permission list such as sign_event:1,nip44_encrypt. The bunker stores it with the client and shows it in the approval prompt, clients pending and clients list, but does not hold the client to it: clients list only what they want pre-approved and expect the rest to work too. Coracle, for one, asks for no sign_event:1 and still posts notes. What the card signs is decided by [signing] alone, the same for every admitted client.
What an admitted client can do is also bounded by signing: with policy = "allowed_kinds" the bunker signs only those kinds, and a rejected request says so in the log and to the client (kind 7 is not in signing.allowed_kinds). Encryption and decryption requests are not filtered.
Running without the card
The bunker starts whether or not the card is inserted and watches the readers. While the card is out, connect, ping and get_public_key work, the last one from the public key remembered in user.pubkey, and signing and ECDH requests get the error Keycard not present. Insert the card and the session opens; remove it and the session closes, together with any cached ECDH secrets. If pcscd stops or restarts, the session closes the same way and the card is opened again once pcscd is back. A card whose key UID is not the remembered one is refused before any PIN is sent, so the identity cannot change under a running bunker. Delete user.pubkey after changing key_path or loading a new key.
If another program uses the card while the bunker runs, the session is reopened on the next request. The PIN is sent at most once per card and per PIN: a wrong PIN is remembered in pin.rejected across restarts, and when the card reports a single attempt left the bunker does not spend it. A refused card shows up in the log with the reason, and clients get Keycard refused: ....
The PIN comes from the first of: keycard.pin in the config, the KEYCARD_NOSTR_BUNKER_PIN environment variable, a systemd credential named pin, a prompt when running in a terminal.
As a service
keycard-nostr-bunker service install -c ~/.config/keycard-nostr-bunker/config.toml
This writes a systemd user unit for that config, pointing at the keycard-nostr-bunker command you ran, then enables and starts it. A service cannot prompt for the PIN, so the install refuses unless keycard.pin is set or a file named pin sits next to the config; that file is passed to the service as a systemd credential and the PIN stays out of the config. keycard-nostr-bunker init writes it if you answer yes to storing the PIN, or create it with:
(umask 077 && echo 123456 > ~/.config/keycard-nostr-bunker/pin)
Useful afterwards:
keycard-nostr-bunker url # the bunker URL; it is not in the journal
journalctl --user -u keycard-nostr-bunker -f # logs
loginctl enable-linger # keep running while logged out
keycard-nostr-bunker service uninstall
pcscd must be running. The bunker stops cleanly on SIGTERM.
Starting before anyone logs in. With linger the service starts at boot, but on Fedora, Debian and Ubuntu pcscd serves only programs in an active login session: its polkit policy (org.debian.pcsc-lite.access_pcsc and access_card) refuses the rest. Until someone logs in, the bunker logs Access denied and exits, and systemd starts it again every 5 seconds. A polkit rule lets the service's user reach the card without a session. Run this as that user:
sudo tee /etc/polkit-1/rules.d/50-keycard-nostr-bunker.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.user == "$USER") {
return polkit.Result.YES;
}
});
EOF
polkit reads the rule straight away. It lets every process of that user use the card readers without a login session, over SSH for example; signing still needs the card's PIN.
Supported NIP-46 methods
| Method | Card operation |
|---|---|
connect |
none; enrolls the client when the secret matches |
get_public_key |
EXPORT KEY (public) at key_path, cached |
sign_event |
SIGN, BIP-340 Schnorr, after the signing policy |
nip44_encrypt, nip44_decrypt |
ECDH on the card, NIP-44 v2 on the host |
nip04_encrypt, nip04_decrypt |
ECDH on the card, AES-256-CBC on the host |
ping |
none |
Every method but connect requires an admitted client. Every request must be signed by its pubkey, carry a matching id, and be no more than a minute old; the bunker checks this itself and does not trust the relay.
Relays
The relay is only a mailbox for signer traffic. It must accept kind 24133 events without NIP-42 AUTH and be reachable from the bunker and from every client; it does not have to be a relay you post to. wss://relay.nsec.app exists for NIP-46 traffic; wss://nos.lol and wss://relay.primal.net, the defaults, work too. wss://relay.damus.io rate-limits a busy bunker key, so a reply can be dropped; the log shows rejected reply ... rate-limited. Use two. If a relay closes the subscription, the bunker subscribes again after a delay that grows each time; a relay that demands NIP-42 AUTH cannot be used, since the bunker does not authenticate. Both the bunker and the clients only make outbound connections, so NAT and firewalls need no configuration.
Tests
pip install -e '.[dev]'
pytest
The suite covers BIP-340 and NIP-44 against the official vectors, the NIP-46 dispatcher, and the Keycard signer against a fake card. No card is needed.
Limitations
- No per-request approval; the signing policy is static.
- No NFC transport.
- Relay AUTH (NIP-42) and the
nostrconnect://flow are not supported.
How it is built
keycard-nostr-bunker is developed with substantial help from an AI coding assistant, Claude. I decide what gets built, read and test what goes in, and maintain it myself. What keeps that honest is in the repository: a pytest suite of about 200 tests that runs with flake8 and mypy on every pull request, and BIP-340 signing and NIP-44 encryption checked against their official test vectors. Signing and ECDH on the card have also been run end to end on a real Keycard with applet 4.0.1, through a public relay.
License
MIT
Metadata
Release files for keycard-nostr-bunker 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| keycard_nostr_bunker-0.1.0.tar.gz | 78.2 kB | With provenance | Details for keycard_nostr_bunker-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| keycard_nostr_bunker-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for keycard_nostr_bunker-0.1.0-py3-none-any.whl |
Total release size: 129.2 kB
Release files / keycard_nostr_bunker-0.1.0.tar.gz
| Download URL | keycard_nostr_bunker-0.1.0.tar.gz |
|---|---|
| Size | 78.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d89d93fd5a0eb3a3443bbd5e2353ea3d1ab1f154b3c4495c5707bcbab9c9b65f
|
|
BLAKE2b-256 checksum How to use checksums |
a63a6140f13d7f733db112a751d820f0de584de7aff159c39978ea9ee33dc3e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / keycard_nostr_bunker-0.1.0-py3-none-any.whl
| Download URL | keycard_nostr_bunker-0.1.0-py3-none-any.whl |
|---|---|
| Size | 51.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8bf6d1b0c2cf8e7251cdf6c51696f04f0f3212ee497b19dab8847d107136cd46
|
|
BLAKE2b-256 checksum How to use checksums |
95b61e41897b3b67cf9fc6eb5cc4d1889fe9b53ea44df6eb74b97b6e9bf670fa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for keycard-nostr-bunker 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for keycard_nostr_bunker-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.