innis / nostr-nip46
NIP-46 (Nostr Connect / bunker) remote-signer protocol for PHP
Requires
- php: ^8.4
- innis/nostr-core: ^0.5.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.1
- phpstan/phpstan: ~2.2.2
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.4.6
README
NIP-46 (Nostr Connect / "bunker") remote-signer protocol for PHP — both roles
Both sides of NIP-46:
- the bunker (
Nip46Bunker) — a process that holds a key and answersconnect/get_public_key/sign_event/nip44_*/nip04_*requests that Nostr apps send over a relay. A request the app was granted is answered straight away; anything else is held in a queue for an explicit approve/reject decision, so a policy layer or a human stays in the loop. - the client (
Nip46Client) — the app side that pairs with abunker://URL, asks the remote signer for the user's public key, and has events signed on the user's behalf.
The two roles share one wire vocabulary (Nip46Method, Nip46Request/Nip46Response, the envelope
codec, the subscription filter) and one transport port, and the test suite runs the shipped client
against the shipped bunker over real cryptography.
The library performs no network I/O of its own: the relay transport arrives as an injected port, so it is relay-pool-agnostic and the protocol logic is tested entirely against in-memory doubles.
Features
- Bunker role - subscribes for kind-24133 requests addressed to the user, authenticates each client's
connectthrough an injected authenticator (one or many named secrets), and dispatches every NIP-46 method. - Client role - connects to any
bunker://URL, correlates responses to requests, verifies that a signed event is authored by the connected identity and carries a valid signature, and surfacesauth_urlchallenges to a listener as a validatedAuthUrl— onlyhttp(s)URLs reach the host, so a malicious bunker cannot hand it ajavascript:orfile:URI to open. Timeouts, rejections, malformed or forged responses, and a request the session cipher cannot carry come back as typedNip46Failurevalues, never exceptions. - Per-app attribution - the authenticator maps a secret to an opaque app id, and every queued request carries it, so a host can label requests and apply per-app policy.
- Per-app permissions - an injected authoriser decides, per request, whether an app's grants cover
it, using the spec's own
method[:params]vocabulary as aPermissionvalue object. A granted request is answered immediately; an ungranted one is queued for the host to decide, never refused. - Approval queue - any request the app was not granted —
sign_event,get_public_keyor thenip04_*/nip44_*operations — is queued and answered only when the host callsapproveorreject; a listener fires whenever the queue changes. Each queued request is identified by the id of the event that carried it, so concurrent clients reusing the same request id can never displace each other. The queue is bounded: past capacity a further request is answered with atoo many pending requestserror rather than queued silently. - Activity audit - an optional activity listener is notified of every request the bunker answers on
its own, with the method, the
AppIdit was answered for, the counterparty public key of a crypto operation, and anAnswered/Failedoutcome. Queued requests are excluded: the host records those at its own approve/reject decision. - NIP-44 with NIP-04 fallback - each peer's cipher is detected on first contact and reused for replies, in both roles. NIP-04 is unauthenticated and deprecated upstream; the fallback exists only for older counterparties.
bunker://andnostrconnect://URLs - both pairing directions, parsed and formatted by value objects sharing one query parser. A bunker-initiated pairing starts from abunker://URL the signer mints; a client-initiated one starts from anostrconnect://URL the client mints, which the host hands toacceptNostrConnect— the bunker then listens on the client's own relays, echoes the URL's secret to it, and treats it as connected without anyconnectrequest.restorePairingre-establishes such a pairing after a restart.- Client-supplied relays - a client-initiated pairing's relays are recorded per client and published
to alongside the signer's own, so a client works whether or not it honours the
switch_relaysmigration hint (which reports the signer's own set and switches nothing). - Client metadata is a hint, never a grant - the
name/url/image/permsof anostrconnect://URL are parsed and handed to the host to display and decide on. The bunker never authorises anything from them, and the optionalperms/ metadata arguments to aconnectrequest are ignored. The client role does not send them. - Transport-agnostic - the one relay-facing surface is the
Nip46TransportInterfaceport, shared by both roles; wire it to any relay client (for exampleinnis/nostr-client). - Ready-made signer -
LocalNip46Signerimplements the signing port overinnis/nostr-core's crypto for a key held in process; supply your own implementation for hardware or custom key custody. - Clean architecture - strict layer separation, domain objects from
innis/nostr-core, PHPStan level 9.
Requirements
- PHP 8.4 or higher
innis/nostr-core- core Nostr protocol entities and crypto interfaces
Installation
composer require innis/nostr-nip46
Running a bunker
Provide four things and the bunker handles the protocol.
1. A signer — LocalNip46Signer for a key held in process (for a self-hosted signer, the key
decrypted from a NIP-49 ncryptsec at unlock), or your own Nip46SignerInterface implementation over
whatever holds the key.
2. A transport — implement Nip46TransportInterface to subscribe and publish on your relays.
3. An authenticator — implement Nip46AuthenticatorInterface to validate a client's connect
secret (a ?ConnectSecret — null when the client sent none) and return an opaque AppId (or null
to reject). Returning a distinct id per secret is what lets you run many named pairings off one signer
and attribute each request to an app.
Secret-reuse policy lives in your authenticator, not the library: a secret can be a durable per-app
pairing token or single-use (reject a secret once a connection has been established). authenticate is
called exactly at connection time and its non-null return is what establishes the connection, so
enforce whichever policy you want there against your own store — the library holds no secret state of
its own.
use Innis\Nostr\Core\Domain\Collection\RelayUrlCollection; use Innis\Nostr\Core\Infrastructure\Time\SystemClock; use Innis\Nostr\Nip46\Application\Service\Nip46Bunker; $bunker = new Nip46Bunker($transport, $signer, $authenticator, $authoriser, new SystemClock()); $bunker->start($relays); // $relays: RelayUrlCollection echo $bunker->bunkerUrlFor($appSecret); // $appSecret: ConnectSecret; bunker://<pubkey>?relay=...&secret=... // Or accept a URL the client minted, pairing it to an app you decided on: $bunker->acceptNostrConnect(NostrConnectUrl::tryFromString($pasted), $appId); // Queued requests carry the app id the client authenticated as: $request->getAppId(). // Decide on them when they arrive: $bunker->setQueueListener($yourPolicyOrUiListener); foreach ($bunker->getPending() as $request) { // inspect $request->getDetail() — a SignEventDetail, CryptoDetail or GetPublicKeyDetail — then: $bunker->approve($request->getId()); // or $bunker->reject($request->getId()); } $bunker->stop();
4. An authoriser — implement Nip46AuthoriserInterface to say whether an app's grants cover a
Permission (sign_event:1, nip44_decrypt, …). It decides answer now versus ask the host, never
refuse: a request the app was not granted is queued for approve / reject exactly as a signing
request is. PermissionCollection::grantable() lists every permission the bunker will ask about, and
fromPermsString / toPermsString parse and render the spec's comma-separated form.
connect, ping, switch_relays (which reports the signer's own relay set) and logout carry no
capability and are always answered. get_public_key, sign_event and the four nip04_* / nip44_*
methods are answered when granted, and queued when not.
For an audit trail of what each connected app did, register an activity listener — the bunker's optional
observability hook, mirroring setQueueListener:
$bunker->setActivityListener($yourAuditSink); // notified as each autonomous request is answered
It receives a BunkerActivity for every request the bunker answers on its own, carrying the method
name, the resolved AppId, the counterparty public key of a crypto operation, and an Answered /
Failed outcome. A queued request never reaches it — record those where you decide them, at approve /
reject (see docs/adr/0020).
Connecting as a client
Provide a transport (the same port as the bunker), a throwaway session key, and an implementation of
the pending-response port for your runtime — awaiting a reply is deliberately left to the host so the
library carries no event-loop dependency (see docs/adr/0008). On amp, the implementation is ~40
lines around a DeferredFuture; a synchronous loopback needs only an array.
use Innis\Nostr\Core\Domain\ValueObject\Identity\PrivateKey; use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Signer; use Innis\Nostr\Core\Infrastructure\Time\SystemClock; use Innis\Nostr\Nip46\Application\Service\Nip46Client; use Innis\Nostr\Nip46\Domain\Failure\Nip46Failure; use Innis\Nostr\Nip46\Domain\ValueObject\BunkerUrl; use Innis\Nostr\Nip46\Infrastructure\Crypto\LocalNip46Signer; $client = new Nip46Client( $transport, // your Nip46TransportInterface LocalNip46Signer::create(PrivateKey::generate()), // ephemeral session key Secp256k1Signer::create(), // verifies returned events new SystemClock(), $pendingResponses, // your Nip46PendingResponsesInterface ); $client->setAuthUrlListener($yourAuthUrlListener); // optional: surfaces auth_url challenges $bunkerUrl = BunkerUrl::tryFromString($pastedUrl) ?? exit('bad bunker URL'); $userPublicKey = $client->connect($bunkerUrl); if ($userPublicKey instanceof Nip46Failure) { exit($userPublicKey->describe()); } $signed = $client->signEvent($unsignedRumour); // Event|Nip46Failure $client->close();
connect() performs the handshake (connect with the URL's secret, then get_public_key) and returns
the user's public key; signEvent() verifies the returned event's author and signature before handing
it back. Every anticipated outcome — timeout, rejection, invalid or forged response, a request too
large for the session cipher — is a returned Nip46Failure with a reason and a human-readable
describe().
To have the remote signer encrypt or decrypt a payload as the user's identity, call one of the four
typed cipher methods — each takes the counterparty's PublicKey and returns the resulting string (or a
Nip46Failure):
$ciphertext = $client->nip44Encrypt($peerPublicKey, 'gm'); // string|Nip46Failure $plaintext = $client->nip44Decrypt($peerPublicKey, $ciphertext); // string|Nip46Failure // nip04Encrypt / nip04Decrypt exist for legacy counterparties.
The client offers one typed method per capability rather than a general call(method, params) escape
hatch: that keeps signEvent() the only way to obtain a signed event, so the author-and-signature check
it performs cannot be bypassed (see docs/adr/0007).
Run the worked end-to-end example (both roles, real secp256k1 + NIP-44 crypto, no network):
php examples/remote_signer.php
Architecture
- Domain - the protocol vocabulary (
Nip46Method,Permission,Nip46Request,Nip46Response,UnsignedEventInput,PendingSignRequest,BunkerUrl,EnvelopeCipher,Nip46Failure), theBunkerSessionandClientSessionstate machines with their sharedSeenEventIdsredelivery guard, theNip46SignerInterfacecapability, the subscription filter factory, and theNip46EnvelopeCodec. - Application - the transport / listener / subscription / pending-response / auth-url / activity
ports and the
Nip46BunkerandNip46Clientorchestrators. - Infrastructure -
LocalNip46Signer(the signing port overinnis/nostr-corecrypto for an in-process key) andNip46EventHandler(adaptsinnis/nostr-core's relay event handler to the listener port). Network transport and response-awaiting stay host-supplied by design.
Design rationale lives in docs/adr/.
Development
composer test # phpunit + phpstan (level 9) composer check-style # php-cs-fixer dry run composer check-rector # rector dry run