innis / nostr-core
Core domain entities and services for Nostr protocol implementation
Requires
- php: ^8.4
- ext-ctype: *
- ext-filter: *
- ext-gmp: *
- ext-intl: *
- ext-mbstring: *
- ext-openssl: *
- ext-sodium: *
- paragonie/ecc: ^2.5
- paragonie/sodium_compat: ^2.5.1
Requires (Dev)
- ext-ffi: *
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.2
- phpstan/phpstan: ~2.3.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.7.0
Suggests
- ext-ffi: Native libsecp256k1 acceleration and NIP-49 scrypt (required for NIP-49)
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v0.10.0
- v0.9.4
- v0.9.3
- v0.9.2
- v0.9.1
- v0.9.0
- v0.8.3
- v0.8.2
- v0.8.1
- v0.8.0
- v0.7.1
- v0.7.0
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.4
- v0.5.3
- v0.5.2
- v0.5.1
- v0.5.0
- v0.4.0
- v0.3.17
- v0.3.16
- v0.3.15
- v0.3.14
- v0.3.13
- v0.3.12
- v0.3.11
- v0.3.10
- v0.3.9
- v0.3.8
- v0.3.7
- v0.3.6
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.6
- v0.2.5
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.9
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-10-12 07:11:29 UTC
README
A PHP library implementing core domain entities and services for the Nostr protocol, built with Clean Architecture principles.
Code is organised around domain concepts (events, identities, tags, messages) rather than NIP numbers: an unsigned draft is a Rumour value object, and signing it mints the signed Event entity, regardless of which NIP defines the event kind. Domain entities and value objects are immutable, services are stateless, and the package provides building blocks for relays, clients, and web applications without imposing architectural decisions on consumers. See ADR-0100 for the organising rationale and ADR-0103 for the rumour/event split.
Important
Install the native libsecp256k1 library (via the ffi extension) for any server-side or long-lived signer. When it is absent, signing, public-key derivation, and ECDH fall back to a pure-PHP implementation that is not constant-time and cannot be made so. A local or co-located attacker able to measure signing/ECDH timing could in principle recover private-key material, so a relay, a NIP-46 remote signer/bunker, or any service that repeatedly signs with a fixed key should confirm the native path is active before deploying, and a non-CLI host (php-fpm, a web SAPI) needs ffi.enable=true or opcache.preload, because PHP's default ffi.enable=preload gives it no FFI. The pure-PHP fallback is intended for portability and low-exposure client use, not a hardened signing oracle. See Security and SECURITY.md.
Features
- Broad Nostr protocol coverage, listed NIP by NIP below
- Clean Architecture with strict layer separation
- Domain-driven design with pure business logic
- Comprehensive cryptographic support using secp256k1
- Native libsecp256k1 FFI acceleration covering BIP340 sign/verify, x-only pubkey derivation, and NIP-44 ECDH — automatic pure-PHP fallback when the C library is unavailable (the fallback is not constant-time; see Security)
- Bech32 and bech32m encoding/decoding via a single
Bech32Codec(NIP-19 prefixes plus BIP-350 bech32m variants), selected through theBech32Variantenum - NIP-19 entities as distinct value objects (
Npub,Note,Nprofile,Nevent,Naddr) behindNip19EntityInterface— each carries only the fields its variant has, and encodes itself - Content-reference extraction (event, pubkey, relay and quote references from tags and content) and reply-chain analysis
- Typed, immutable domain collections and a subscription model
- NIP compliance validation for NIP-01, NIP-02, NIP-04 and NIP-09 events (
NipComplianceValidator) - Event acceptance validation (
EventValidator) against the host'sEventLimits— content length, tag count and acreated_atwindow, which NIP-01 leaves to the relay and NIP-11 advertises; defaults 65536 characters, 5000 tags, ten years behind to one hour ahead (see ADR-0131) - Type-safe message handling with domain objects at all boundaries
- Extensive test coverage with PHPStan level 9
Requirements
Declared in composer.json:
- PHP 8.4 or higher
ext-ctype(ctype_digitinDecimalIntegerParser, which reads every untrusted decimal string: a coordinate's kind, theexpirationandpublished_attimestamps, a file'ssize, a zap request'samountand the amounts a mint keyset keys)ext-filter(FILTER_VALIDATE_INTin the same parser, which refuses a decimal that overflows an integer)ext-gmp(bignum arithmetic for the pure-PHP secp256k1 signing and ECDH path and for the NUT-12 DLEQ check; required transitively byparagonie/ecc, so the package cannot install without it even on a host that always uses the nativelibsecp256k1path)ext-intl(NFKC password normalisation in NIP-49)ext-mbstring(themb_check_encodingUTF-8 check on every string a value is read from: event content, tags, subscription ids, filter values and search terms, coordinates, external content ids and event metadata, and every NIP-04 and NIP-44 plaintext, encrypted or decrypted, see ADR-0116;mb_strlenforEventContent::getLengthand a subscription id's 64-character limit;mb_strtolowerforHashtagand search-filter matching on untrusted event content)ext-openssl(AES-256-CBC for NIP-04)ext-sodium(XChaCha20-Poly1305 AEAD for NIP-49, the constant-time hex codec for keys, ids and signatures, see ADR-0120, the constant-time scalar comparison inPrivateKey, andsodium_memzeroon secret buffers, the NIP-44 message keys included; NIP-44 itself encrypts withsodium_compat's ChaCha20 and authenticates withhash_hmac)paragonie/ecc(pure-PHP secp256k1 fallback, and the curve arithmetic of the NUT-12 DLEQ check, which has no native path)paragonie/sodium_compat(raw ChaCha20 keystream with explicit block counter for NIP-44, whichext-sodiumdoes not expose)
Declared under suggest in composer.json:
ext-ffiis needed only by NIP-49, which has no pure-PHP fallback. TheSecp256k1Signer::create()/Secp256k1Ecdh::create()factories use it to reachlibsecp256k1when it is loaded, and without it fall back to the pure-PHP path, so consumers who do not use NIP-49 can run withoutext-ffiand still construct the adapters through::create().
Optional system libraries
-
libsecp256k1— when present, Schnorr signing, verification, public-key derivation, and NIP-44 ECDH use the native C library (reached viaext-ffi) for significantly faster performance. Without it, the library falls back to a pure-PHP implementation viaparagonie/eccautomatically. That fallback is not constant-time, so installing the native library is a security measure as well as a performance one for any server-side or long-lived signer; see Security. -
libsodium— required by NIP-49 scrypt derivation, which callscrypto_pwhash_scryptsalsa208sha256_llthroughext-ffi. Typically already installed whereverext-sodiumis. A non-CLI SAPI under PHP's defaultffi.enable=preloadreaches neither library unless the host setsffi.enable=trueor preloads this library throughopcache.preload; see Native FFI Acceleration. -
Running the test suite requires
ext-ffiandlibsecp256k1, even though using the library does not. The NIP-49 tests need FFI unconditionally (see ADR-0039) and the native-path crypto tests need the shared library. On a host without them, runcomposer test-no-ffi, which excludes theffigroup and is the same command CI runs to verify the pure-PHP deployment.
Installation
composer require innis/nostr-core
Quick Start
Cryptographic operations (signing, verification, public-key derivation, ECDH) are exposed as Domain service interfaces with Infrastructure implementations. The Secp256k1Signer and Secp256k1Ecdh pick an FFI-accelerated path when libsecp256k1 is available and fall back to pure PHP otherwise; both paths produce byte-identical results, so callers do not need to care which one runs for correctness. The two are not equivalent for timing side channels, though — see Security before running a server-side or long-lived signer on the pure-PHP path.
Key Generation
use Innis\Nostr\Core\Domain\ValueObject\Identity\KeyPair; use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Signer; $signatureService = Secp256k1Signer::create(); $keyPair = KeyPair::generate($signatureService); echo $keyPair->getPrivateKey()->toBech32(); // nsec1... echo $keyPair->getPublicKey()->toBech32(); // npub1...
Event Creation and Signing
Rumour::draft builds an unsigned event (content, tags and created_at default to empty, none and now, and an addressable kind with no d tag gains ["d", ""]); signing it mints a signed Event. A RumourFactory, constructed for the author of every draft it makes, adds the drafts that need tags built for them — kind 1 notes and replies, which tag what their content mentions, reposts, reactions, deletion requests, NIP-17 private messages and reactions, NIP-42 and NIP-98 auth, file metadata and long-form articles. Every draft is stamped with the current instant; withCreatedAt() restamps one that needs a fixed created_at:
use Innis\Nostr\Core\Domain\Factory\RumourFactory; use Innis\Nostr\Core\Domain\ValueObject\Content\EventContent; use Innis\Nostr\Core\Domain\ValueObject\Content\EventKind; use Innis\Nostr\Core\Domain\ValueObject\Protocol\Rumour; $rumour = Rumour::draft( $keyPair->getPublicKey(), EventKind::fromInt(EventKind::TEXT_NOTE), EventContent::fromString('Hello Nostr!'), ); $signedEvent = $rumour->sign($keyPair, $signatureService); $signedEvent->verify($signatureService); // bool $reply = new RumourFactory($keyPair->getPublicKey()) ->createReply($signedEvent, EventContent::fromString('A reply')) ->sign($keyPair, $signatureService);
Event::toJson() always encodes the fields the event holds, never the string it was parsed from, so a relayed event carries exactly what was verified — see ADR-0076.
NIP-44 Encryption
ConversationCipher encrypts to and decrypts from a peer in one call: it derives the NIP-44 conversation key from your private key and the peer's public key, uses it once and wipes it. Deriving the key needs an ECDH service; Secp256k1Ecdh::create() follows the same FFI-or-fallback pattern as the signature adapter:
use Innis\Nostr\Core\Domain\Service\ConversationCipher; use Innis\Nostr\Core\Infrastructure\Crypto\Nip44Cipher; use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Ecdh; $cipher = new ConversationCipher(new Nip44Cipher(), Secp256k1Ecdh::create()); $ciphertext = $cipher->encrypt('Hello in private', $senderPrivateKey, $recipientPublicKey); $plaintext = $cipher->decrypt($ciphertext, $recipientPrivateKey, $senderPublicKey);
It is the same capability GiftWrapper encrypts with (see ADR-0109). A peer key the key agreement refuses (off the curve, out of field) is an EncryptionException, the cipher's own failure, carrying the key agreement's message and its EcdhException as the previous exception, so a caller reading a peer's ciphertext catches one exception type (see ADR-0089). Nip44Cipher underneath takes a ConversationKey directly, for a caller that already holds one.
Nip04ConversationCipher is its NIP-04 counterpart, for legacy peers only and marked #[Deprecated]: new Nip04ConversationCipher(new Nip04Cipher(), Secp256k1Ecdh::create()) refuses a plaintext Nip04Cipher would refuse, and a payload of a length it would refuse, before any key agreement, then computes the ECDH shared secret, runs Nip04Cipher under it and wipes it. A peer key the key agreement refuses is reported as the cipher's own EncryptionException — on decryption the one NIP-04 decryption failed — so a caller reading a peer's payload converts one exception type (see ADR-0139).
Nonce generation is injected: Nip44Cipher accepts an optional RandomBytesGeneratorInterface, defaulting to NativeRandomBytesGenerator (PHP's random_bytes) for production. There is no public encryptWithNonce method — see ADR-0014.
Nip44Cipher also takes a maximum plaintext length, defaulting to Nip44Cipher::DEFAULT_MAX_PLAINTEXT_LENGTH (262144 bytes, 256 KiB). encrypt refuses a longer plaintext, and decrypt refuses a payload longer than the base64 length that maximum produces before decoding it. A host that must carry more passes a larger maximum, up to NIP-44's own 4294967295 — see ADR-0117.
Always construct the adapters through their ::create() factories. Direct instantiation via new Secp256k1Signer(null, ...) or new Secp256k1Ecdh(null) exists for dependency injection and testing but stays on the pure-PHP path regardless of whether libsecp256k1 is installed.
Message Handling
use Innis\Nostr\Core\Domain\ValueObject\Protocol\Message\Client\EventMessage; use Innis\Nostr\Core\Domain\ValueObject\Protocol\Message\ClientMessage; $json = new EventMessage($signedEvent)->toJson(); $anyClientMessage = ClientMessage::tryFromJson($json); $eventMessage = EventMessage::tryFromJson($json);
ClientMessage::tryFromJson and RelayMessage::tryFromJson parse a message of unknown type and return the leaf its tag names, or null. Called on a leaf, the same method returns that leaf or null for any other tag — see ADR-0075.
NIP-19 Entities
Each NIP-19 entity is its own final readonly value object — Npub, Note, Nprofile, Nevent, Naddr — implementing Nip19EntityInterface, which declares type(): Nip19EntityType and toBech32(): string. There is no encode on the codec: an entity is minted through its own named constructor and encodes itself. An npub or note is the bech32 form of a PublicKey or EventId, which encode it themselves (PublicKey::toBech32(), EventId::toBech32()), and Npub and Note delegate to them, so each NIP-19 string has exactly one encoder.
use Innis\Nostr\Core\Domain\Service\Nip19Codec; use Innis\Nostr\Core\Domain\ValueObject\Nip19\Nevent; use Innis\Nostr\Core\Domain\ValueObject\Nip19\Nprofile; use Innis\Nostr\Core\Domain\ValueObject\Nip19\Npub; echo Npub::fromPublicKey($publicKey)->toBech32(); // npub1... $nevent = Nevent::tryFromEventId($eventId, $relays, author: $publicKey, kind: $kind) ?? throw new RuntimeException('TLV payload exceeds the encodable size'); echo $nevent->toBech32(); // nevent1...
Npub::fromPublicKey and Note::fromEventId carry no optional records and cannot fail, so they are from; the three TLV entities are try* because an oversized payload has no encoding (see ADR-0051). The optional records mirror the spec: nevent takes relays, author, and kind, nprofile takes relays, and naddr mandates author and kind, so Naddr::tryFromCoordinate takes a whole EventCoordinate rather than letting either go missing. Following NIP-19 and NIP-01, an EventCoordinate (and so an naddr) addresses a replaceable kind with an empty identifier and an addressable kind with any identifier, including the empty one; every other kind is rejected.
Decoding a string whose prefix you do not know goes through the codec, which returns the interface. Because each variant carries only the fields it has, dispatch is on the type — there are no nullable getters to interrogate:
$entity = Nip19Codec::decodeEntity($input); // ?Nip19EntityInterface $publicKey = match (true) { $entity instanceof Npub, $entity instanceof Nprofile => $entity->getPublicKey(), default => null, };
When you already know the prefix, decode with the type that owns it instead: PublicKey::tryFromBech32 for an npub, EventId::tryFromBech32 for a note, and Nprofile::tryFromBech32, Nevent::tryFromBech32 or Naddr::tryFromBech32 for the TLV entities. For the narrower "give me whatever event this string points at" question, parseEventReference accepts a raw hex id, a note, an nevent, an naddr (its first relay kept as the coordinate's relay hint), or a kind:pubkey:d coordinate string, and returns EventId|EventCoordinate|null, as parseEventOrAddressRef does in the TypeScript core. Rationale for the split — and for Nip19EntityType gaining a Note case so five entities map to five cases — is in ADR-0082; the TLV codec is an internal value object per ADR-0104.
Verifying Zap Receipts (NIP-57)
ZapReceipt::tryFromEvent parses a kind 9735 event carrying exactly one signed kind 9734 zap request and one readable BOLT-11 amount that the request agrees with, and returns null for anything else. It authenticates nothing — its getSenderPubkey() is the zap request's author, attacker-chosen until checked. ZapReceiptVerifier applies NIP-57 Appendix F to the parsed receipt, returning null on success or a ZapReceiptVerificationFailure case naming what failed:
use Innis\Nostr\Core\Domain\Service\ZapReceiptVerifier; use Innis\Nostr\Core\Domain\ValueObject\Payment\ZapReceipt; $receipt = ZapReceipt::tryFromEvent($receiptEvent) ?? throw new RuntimeException('Not a zap receipt'); $failure = new ZapReceiptVerifier($signatureService)->verify($receipt, $lnurlProviderPubkey, $expectedLnurl); if (null !== $failure) { throw new RuntimeException('Zap receipt rejected: '.$failure->value); }
You must supply the LNURL provider pubkey, and it is the root of trust. This package never fetches LNURL configuration, so it cannot discover that key — with the wrong one, a receipt from any provider verifies. Read the nostrPubkey from the recipient's own LNURL endpoint. The verifier checks the receipt's signature and the embedded zap request's signature; the latter is not an Appendix F requirement, but without it every Appendix F condition can hold while the provider attributes the zap to an arbitrary sender. See ADR-0079 and SECURITY.md.
Password-Encrypted Private Keys (NIP-49)
The NIP-49 adapter takes the password as a Closure(): string rather than a raw string. It invokes the closure exactly once and sodium_memzeros both the revealed password and its NFKC-normalised copy on the way out:
use Innis\Nostr\Core\Domain\Enum\KeySecurityByte; use Innis\Nostr\Core\Domain\ValueObject\Identity\Ncryptsec; use Innis\Nostr\Core\Domain\ValueObject\Identity\Nip49WorkFactor; use Innis\Nostr\Core\Domain\ValueObject\Identity\PrivateKey; use Innis\Nostr\Core\Infrastructure\Crypto\Nip49Cipher; $adapter = Nip49Cipher::create(new Nip49WorkFactor(encryptLogN: 16, maxDecryptLogN: 22)); $privateKey = PrivateKey::generate(); $ncryptsec = $adapter->encrypt( $privateKey, static fn (): string => readPasswordFromUser(), KeySecurityByte::NotKnownInsecure, ); $stored = (string) $ncryptsec; // ncryptsec1... $decoded = Ncryptsec::tryFromString($stored) ?? throw new RuntimeException('Malformed ncryptsec'); $recovered = $adapter->decrypt($decoded, static fn (): string => readPasswordFromUser());
That wipes the library's copy, not yours. A closure returning a variable you still hold — static fn (): string => $password — leaves your binding readable after the call, so the Closure shape pays off only when it produces the password without the caller retaining it: reading a prompt, unsealing it from a keystore, or decrypting it on demand. If you do keep the password in scope, zero it yourself. See SECURITY.md.
Build the adapter through Nip49Cipher::create(), which probes for libsodium scrypt via ext-ffi; the bare constructor (new Nip49Cipher(...)) is for dependency injection and tests. NIP-49 has no pure-PHP fallback — see ADR-0041 and ADR-0039.
Secret Key Lifecycle
PrivateKey and ConversationKey hold their raw bytes inside a SecretKeyMaterial value object. Callers that need to clear secret material from memory can call zero(); any subsequent operation on that key throws SecretKeyMaterialZeroedException. Infrastructure code that genuinely needs raw bytes uses the bounded expose callback, which hands the closure the secret bytes and sodium_memzeros them when it returns; see ADR-0028:
$derived = $privateKey->expose(static function (string $bytes): string { return derive_something($bytes); }); $privateKey->zero(); $signatureService->sign($privateKey, $message); // throws SecretKeyMaterialZeroedException
Applications that require bounded key-material lifetimes — session-scoped bunker signers, for example — should call $privateKey->zero() explicitly at the end of the scope that owns the key. See ADR-0015 for why the destructor is not relied upon.
Examples
Runnable scripts live in examples/; run one with php examples/<name>.php:
sign_and_verify.php— generate a key pair, create and sign a text note, verify itnip44_encrypt_decrypt.php— derive a NIP-44 conversation key via ECDH and encrypt/decrypt a messagenip49_password_encrypt.php— encrypt a private key under a password to anncryptsecand recover it (requiresext-ffiand libsodium)giftwrap_direct_message.php— seal and gift-wrap a NIP-17 private message to its chat room, then unwrap the recipient's copyrelay_auth_flow.php— issue a NIP-42Challenge, answer it from the client side, and validate the answer withNip42Validator
The examples/ directory is covered by PHPStan and php-cs-fixer in CI, like src and tests, and CI runs every example with composer examples.
Supported NIPs
| NIP | Description | Support |
|---|---|---|
| NIP-01 | Basic protocol flow | Event creation, signing, verification, serialisation, the id computed over JSON that escapes a control character NIP-01 does not name as \u00XX, as JSON encoders and the rest of the ecosystem do (ADR-0123); the machine-readable ReasonPrefix on OK and CLOSED replies (the NIP-01 words, unsupported, NIP-42's auth-required and NIP-43's info), read as error for a refusal that names none (nostr-adrs ADR-0031); an EoseMessage carries NIP-67's hint array (getHints()), read and written back; EoseMessage::hinting($subscriptionId, EoseHint::Auth) writes a hint from NIP-67's EoseHint vocabulary (finish, more, auth) and hasHint(EoseHint::Auth) reads one, so a relay sends ["auth"] and a client reads it without spelling the word (nostr-adrs ADR-0091); EventVersion to decide which of two replaceable events survives |
| NIP-02 | Follow list | Kind 3 with contact list tags |
| NIP-04 | Encrypted direct messages | Deprecated — use NIP-44. Kind 4 with recipient validation; Nip04Cipher for AES-256-CBC encrypt/decrypt over a 32-byte ECDH shared secret. Unauthenticated and malleable; both interface methods carry #[Deprecated]. Shipped for kind-4 interoperability only — read SECURITY.md before using it |
| NIP-05 | DNS-based identity | Identifier parsing and HTTP verification |
| NIP-09 | Event deletion | Kind 5 with deletion tag validation and isDeletion() detection; RumourFactory::createDeletion names every target by its id, a replaceable or addressable one by its coordinate as well, with its k tag (nostr-adrs ADR-0124) |
| NIP-10 | Reply conventions | Reply chain analysis with root/reply/mention markers; RumourFactory::createReply answers a kind 1 note with a kind 1 note that names the root and the root's author, read from the tag naming the root whether or not it is marked (nostr-adrs ADR-0076) |
| NIP-11 | Relay information | Relay metadata fetching and parsing; Nip11Info::getPubkey() and getSelf() read the operator's and the relay's own public key |
| NIP-17 | Private direct messages | Kind 14 with NIP-44 encryption and gift wrap (kind 1059); Rumour::getChatRoom() is the author plus the p tags, RumourFactory::createPrivateMessage and createPrivateReaction take the room's receivers, leaving the author out unless no one else is in it, when the room is the author alone and its one p tag names the author; a private reaction tags the author of the message it answers last among its p tags, the sender included; GiftWrapper::wrapForChatRoom wraps once to every member, so a room of one is wrapped once to its author; a rumour may serialise to at most 163840 bytes (GiftWrapper::MAX_RUMOUR_LENGTH) |
| NIP-18 | Reposts | Kind 6/16 with embedded event extraction and quote detection; every note, nevent and naddr mention becomes a q tag, written by ContentReferenceTagBuilder for RumourFactory::createTextNote and createReply |
| NIP-19 | Bech32 encoding | npub, nsec, note, nprofile, nevent, naddr — each entity a distinct value object behind Nip19EntityInterface, encoding itself within NIP-19's 5000 characters and decoded through Nip19Codec; Bech32Codec also supports the BIP-350 bech32m variant for non-NIP consumers (e.g. FROSTR bfgroup1… / bfshare1… / bfonboard1…) via the Bech32Variant enum |
| NIP-22 | Comments | Kind 1111 with root/parent kind tags and reply chain analysis over E/A/I root and e/a/i parent tags, an I/i value read as a NIP-73 ExternalContentId whose hint is a WebUrl, kept exactly as written, fragment included, and stated only when every tag carrying one writes it identically (nostr-adrs ADR-0090); a comment, or a kind 1244 voice message comment read as one, is a reply when it resolves a root or a parent |
| NIP-23 | Long-form content | Kind 30023 as addressable events; LongformMetadata carries its topics as a HashtagCollection, and its image as a WebUrl: an http or https URL a client opens or fetches, such as an image, a stream or a web page, checked by HttpUrl's rules and kept exactly as written (nostr-adrs ADR-0079) |
| NIP-24 | Extra metadata | Hashtag carries the lowercase rule for t tag values; TagCollection::getHashtags() and EventContent::extractHashtags() both return a deduplicated HashtagCollection |
| NIP-25 | Reactions | Kind 7 event support; RumourFactory::createReaction names the target by its e, p and k tags, adds an a tag only for an addressable target, and writes an optional relay hint where the target can be found |
| NIP-28 | Public chat | Kind 40-44 channel event types; a kind 42 threads by its marked e tags, its root naming its channel, so only a reply marker makes it a reply, and createReply answers one with a kind 42 rooted at that channel (nostr-adrs ADR-0012, ADR-0076) |
| NIP-37 | Draft wraps | Kind 31234 (EventKind::DRAFT_WRAP); RumourFactory::createDraftWrap writes a draft's d identifier and its k kind over the content it is given, the draft already NIP-44 encrypted or empty for a deleted draft; it takes no expiry, and a wrap's expiration is added with Rumour::withExpiration (nostr-adrs ADR-0118) |
| NIP-40 | Expiration | Expiry detection via isExpiredAt() against the instant the caller supplies, expired once any expiry stated has passed; expiresAt() is the earliest stated expiry that parses, derived by ExpirationDerivation::earliestStated(), which a store calls on the tag values it indexed to answer without decoding the event (nostr-adrs ADR-0011); Rumour::withExpiration writes the one expiration tag, replacing any the draft carries |
| NIP-42 | Authentication | AUTH messages over a non-empty Challenge, and Nip42Validator for the relay side, checking an event against the RelayChallenge it answers; each Nip42ValidationFailure names the reasonPrefix() a relay answers it with, auth-required for a replaced challenge and invalid for every other (nostr-adrs ADR-0026) |
| NIP-44 | Encrypted payloads | NIP-44 v2 encrypt/decrypt with ECDH, ChaCha20, HMAC-SHA256; plaintext of 1 to 262144 bytes by default, configurable up to 4294967295, with the extended six-byte length prefix from 65536; a payload over the ceiling is refused by its length, and one within it that starts with # is reported as an unsupported version |
| NIP-45 | Counting | COUNT messages; the reply carries an EventCount that may be approximate |
| NIP-49 | Private key encryption | Password-encrypted ncryptsec with scrypt + XChaCha20-Poly1305 |
| NIP-50 | Search | Search filter support; a filter matched locally holds every term of its search in its content, and ignores a key:value extension |
| NIP-51 | Lists | EventKind constants for every standard list kind NIP-51 defines (kind 3 and 10000–10102, among them 10021, 10054 and 10064) and every set kind (30000–39092); the list and set contents are read through TagCollection |
| NIP-53 | Live activities | LiveEventMetadata for kind 30311, its image and streaming held as WebUrls, kept as written; a kind 1311 live chat message's e tags name its parent and it has no root, and createReply answers one with a kind 1311 carrying the parent's activity a tag (nostr-adrs ADR-0012, ADR-0076) |
| NIP-57 | Lightning zaps | Zap request/receipt parsing, BOLT-11 amount extraction (a mixed-case invoice has none), and Appendix F receipt verification via ZapReceiptVerifier (parsing alone authenticates nothing) |
| NIP-59 | Gift wrap | GiftWrapper seals a rumour to its recipient and wraps it under an ephemeral key, verifying the gift wrap's signature and then the seal's on unwrap and returning a GiftWrapUnwrapFailure for a wrap it cannot open or a seal that carries any tag but expiration tags holding a valid NIP-40 timestamp; it unwraps a kind 1059 gift wrap and a kind 21059 ephemeral gift wrap alike; a rumour must state its id, which must be the one its fields hash to, may be of any kind, and may serialise to at most 163840 bytes (GiftWrapper::MAX_RUMOUR_LENGTH), the largest whose seal still fits NIP-44's default 262144-byte plaintext; a larger one is refused before anything is signed |
| NIP-61 | Nutzaps | Kind 9321 cashu proof parsing and amount extraction; a proof's amount counts only when it is a JSON integer; Nutzap::getCashuAmount() is the proofs' sum in the unit they state (sat by default, usd, eur or any other, never converted), and getAmount() its bitcoin amount, only for a sat or msat unit; a sat or msat total above 1 BTC refuses the nutzap (nostr-adrs ADR-0080). NutzapInfo::tryFromEvent reads a recipient's kind 10019: its one P2PK pubkey, bare or 02/03-prefixed and never its author's own key, its mint URLs kept as written and its relay URLs; Nutzap::getMint() is the u tag kept as written, and getProofs() the proofs that state an amount, each with the key its NUT-10 P2PK secret locks it to alone (P2pkSecretParser) and carried whole for the DLEQ check: NutzapProof::getKeysetId(), getSecret(), getUnblindedSignature() (C) and getDleq(), a NutzapDleq with getE(), getS() and getR(), each kept as written and null when missing or mistyped. NutzapVerifier::verify($nutzap, $recipientsInfo) checks the recipient, the mint, that a proof exists and every proof's lock, in that order, returning a NutzapVerificationFailure or null. Nutzap::getKeysetIds() names the keysets its proofs name, once each, as written; MintKeysetCollection::fromKeysResponse() reads a mint's NUT-01 keys response (GET /v1/keys/{keyset_id}, which the host fetches from the verified nutzap's mint, only for an id that is hex and a mint written with no query or fragment, and decodes with JsonWireFormat::decode) as MintKeysets, each its id, unit and keys by amount kept as written, and findById() takes the first with an id. NutzapDleqVerifier::verify($nutzap, $keysets), given a CashuDleqVerifier (NUT-12 DLEQ over NUT-00 hash_to_curve, in pure PHP), then checks that a proof exists, that every proof's keyset is supplied, is in the nutzap's unit and has a key for the proof's amount, and that every proof's DLEQ proof verifies against that key, in that order, returning a NutzapDleqFailure or null; a nutzap that has not passed both verifications is never counted (nostr-adrs ADR-0121) |
| NIP-65 | Relay list metadata | Kind 10002 (EventKind::RELAY_LIST); each r tag read by TagReferenceExtractor as a RelayReference whose RelayMarker is read, write or both, an omitted or unknown marker read as both (see ADR-0132) |
| NIP-70 | Protected events | Protected event detection via isProtected(), true for any tag named -, whatever values follow the name, and createRepost embeds no protected event (nostr-adrs ADR-0078) |
| NIP-84 | Highlights | Kind 9802; HighlightMetadata reads its context and comment tags and its source URL, the http(s) r tags marked source or, when none is, those not marked mention, read as one claim (see ADR-0107) |
| NIP-86 | Relay management | Nip86Request, Nip86Response and the Nip86Method names; a response is written with one outcome and read by its error, so an empty error beside a result is a success (nostr-adrs ADR-0037) |
| NIP-92 | Media attachments | FileMetadata::tryFromImetaTag and toImetaTag read and write an imeta tag, which must carry a url and at least one other field, any field at all; what is read of it is the fields FileMetadata models (nostr-adrs ADR-0072) |
| NIP-94 | File metadata | FileEventMetadata is a kind 1063 event's metadata, which must state url, an m that is a MIME type, and x and ox as SHA-256 hex; FileMetadata reads the same tags leniently for a BUD-08 descriptor or a NIP-92 imeta tag, refusing an empty url, reading size only as decimal digits and m only from its part before the first ;, without the spaces and tabs around it, as an RFC 6838 type/subtype (MimeTypeParser, stated in lowercase), keeping every fallback in order, and keeping any other empty field as the empty string (nostr-adrs ADR-0079); FileMetadata::tryFrom returns null, and from throws, for an empty url, a field that is not UTF-8, an m that is not a lowercase type/subtype or a negative size |
| NIP-98 | HTTP auth | Kind 27235 validation: signature, URL (refusing credentials, nostr-adrs ADR-0081), method (matched byte for byte; RumourFactory::createHttpAuth writes Fetch's six standard methods upper-cased and any other as given, nostr-adrs ADR-0070), payload hash (an empty body admits a payload tag only when it holds the empty body's hash, nostr-adrs ADR-0024), timestamp tolerance, NIP-40 expiry; NostrAuthHeaderCodec decodes the Authorization header, matching the scheme token without regard to case, and encodes one only within the 4096 characters it decodes; encodeBlossom and decodeBlossom do the same for a Blossom (BUD-11) header within its own bound of 8000 characters (MAX_BLOSSOM_HEADER_LENGTH, nostr-adrs ADR-0106), writing unpadded base64url and reading that or the padded base64 older Blossom clients write (nostr-adrs ADR-0111) |
| NIP-A0 | Voice messages | Kinds 1222 and 1244; a kind 1244 threads as a NIP-22 comment, and a text reply to either is a kind 1111 comment |
| NIP-C7 | Chats | Kind 9; its first q tag naming an event is its parent, and createReply answers one with a kind 9 quoting it |
Beyond the NIPs listed above, EventKind carries named constants for a broad range of registered kinds (metadata, channels, MLS messaging, polls, cashu wallet events, live events, and more) and EventKind::category() answers EventKindCategory so consumers can classify a kind the library does not otherwise model.
Performance
Native FFI Acceleration
The library can use the system's native libsecp256k1 C library via PHP's FFI extension for cryptographic operations. This provides significant performance gains for applications performing bulk signature verification (relays, indexers).
Operations routed through LibSecp256k1Ffi when the library is loaded:
sign— BIP340 Schnorr signverify— BIP340 Schnorr verifyderivePublicKey— secret to 32-byte x-only pubkeycomputeSharedX— x-only ECDH, the NIP-04 shared secret and the input to a NIP-44 conversation key.LibSecp256k1Ffi::computeSharedXreturns the 32 raw bytes as astring;Secp256k1Ecdh::computeSharedX, theEcdhServiceInterfacecallers use, wraps them inSecretKeyMaterialso they are wiped when released
To install the native library:
# Current Debian/Ubuntu releases (provides libsecp256k1.so.2) sudo apt install libsecp256k1-2 # Older Debian/Ubuntu releases (provides libsecp256k1.so.1) sudo apt install libsecp256k1-1 # macOS (Homebrew) brew install libsecp256k1
No code changes are required. The library detects and uses the native implementation automatically, falling back to pure PHP when unavailable. PHP's default ffi.enable=preload allows FFI only on the CLI and in preloaded code, so under a non-CLI SAPI such as php-fpm the native path is silently unavailable — signing falls back to the pure-PHP implementation, which is not constant-time, and NIP-49 throws — unless the host sets ffi.enable=true or preloads this library through opcache.preload.
Security
See SECURITY.md for the library's security properties, the responsibilities it leaves to the consumer, and the reasoning behind the non-obvious cryptographic decisions.
The most important operational caveat: the pure-PHP cryptography fallback used when native libsecp256k1 is unavailable is not constant-time and cannot be made so (the secret-dependent scalar arithmetic runs on variable-time GMP and the interpreted Zend engine). A local or co-located attacker able to measure signing/ECDH timing could in principle recover private-key material. Any server-side or long-lived signer — a relay, a NIP-46 remote signer/bunker, or any service that repeatedly signs attacker-influenced messages with a fixed key — should install libsecp256k1, enable the ffi extension (on a non-CLI SAPI such as php-fpm, with ffi.enable=true or opcache.preload, since the default ffi.enable=preload allows FFI only on the CLI), and confirm the native path is active before deploying. Secp256k1Signer::backend() and Secp256k1Ecdh::backend() report which path the constructed adapter will take, so that confirmation belongs in a startup assertion rather than a deployment checklist:
use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Backend; use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Signer; $signer = Secp256k1Signer::create(); if (Secp256k1Backend::Native !== $signer->backend()) { throw new RuntimeException('Refusing to start: libsecp256k1 is unavailable and signing would run on the non-constant-time pure-PHP path'); }
The pure-PHP fallback is intended for portability and low-exposure client use, not a hardened signing oracle. The full analysis is in SECURITY.md and ADR-0101.
Architecture
This package follows Clean Architecture principles with strict layer separation:
- Domain Layer: Pure business logic, immutable entities and value objects. Its dependencies are the cryptography (
paragonie/eccandext-sodium, used directly by identity value objects) and three bundled string extensions,ext-mbstring,ext-ctypeandext-filter(see ADR-0100) - Application Layer: Port interfaces for external service integration, and the services that orchestrate one (
Nip42Validator, which reads an injected clock around the domain'sNip42EventChecker, andNip98Validator, which reads an injected clock and a replay guard around the domain'sNip98EventChecker;Nip05Verifier,Nip11Fetcher, each calling the injected HTTP port) - Infrastructure Layer: Implementations of the domain and application interfaces that reach external technology, grouped by concern (
Crypto/,Time/)
Architecture decisions
Design rationale lives in docs/adr/ as immutable, sequentially-numbered Architecture Decision Records — read these before "correcting" a choice that reads like a smell. Each record states the context, the decision, and what it forbids; the filenames are the index.
Dependencies
| Package | Purpose |
|---|---|
paragonie/ecc |
Pure-PHP secp256k1 elliptic curve operations (fallback when FFI unavailable, and always for the NUT-12 DLEQ check) |
paragonie/sodium_compat |
Raw ChaCha20 keystream with an explicit block counter for NIP-44 (not exposed by ext-sodium) |
Testing
# Full suite: Unit + Integration + Compliance + PHPStan (ship gate) composer test # Unit suite only (fast inner loop; skips compliance property fuzz) composer test-unit # Spec-vector and cross-language parity suite composer test-compliance # What a host without ext-ffi and libsecp256k1 can run (the CI pure-PHP leg) composer test-no-ffi # PHPStan analysis (level 9) composer analyse # Fix code style / check it without writing composer fix-style composer check-style # Apply or check the Rector 8.4 modernisation set composer rector composer check-rector # Run every script in examples/, failing on the first that fails composer examples # Check that no tracked path component exceeds 95 characters composer check-path-lengths
CI runs the full gate on PHP 8.4 and 8.5, plus a separate leg with ext-ffi disabled and no libsecp256k1 that runs test-no-ffi — the gmp-only deployment the Requirements section offers is verified rather than assumed.
Filter-set hash
FilterHasher::hash computes a stable, order-independent identity for a NIP-01 REQ filter set, suitable as a subscription dedup key. Filter sets that differ only in the order of their filters, of the fields within a filter, or of the values within a list hash to the same digest; any other difference, even one that selects the same events (a redundant value, a different spelling of an empty window), gives a different digest. The digest is byte-for-byte identical to the TypeScript sibling's hashFilters for every input — including non-ASCII search strings and tag-filter values.
use Innis\Nostr\Core\Domain\Collection\EventKindCollection; use Innis\Nostr\Core\Domain\Service\FilterHasher; use Innis\Nostr\Core\Domain\ValueObject\Protocol\Filter; $filters = [Filter::from(kinds: EventKindCollection::fromInts([1]))]; $key = FilterHasher::hash(...$filters); // lowercase-hex SHA-256
The canonicalisation contract and the cross-language parity rationale are recorded in ADR-0020; the conformance anchors that lock the two runtimes together are asserted in both packages' test suites.
License
MIT License. See LICENSE file for details.