innis / nostr-core
Core domain entities and services for Nostr protocol implementation
Requires
- php: ^8.4
- ext-gmp: *
- ext-intl: *
- ext-mbstring: *
- ext-sodium: *
- paragonie/ecc: ^2.5
- paragonie/sodium_compat: ^2.1
Requires (Dev)
- ext-ffi: *
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.1
- phpstan/phpstan: ~2.2.6
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.5.9
Suggests
- ext-ffi: Native libsecp256k1 acceleration and NIP-49 scrypt (required for NIP-49)
This package is auto-updated.
Last update: 2026-08-06 11:14:33 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-0019 for the organising rationale and ADR-0045 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. The pure-PHP fallback is intended for portability and low-exposure client use, not a hardened signing oracle. See Security and SECURITY.md.
Features
- Complete Nostr protocol implementation
- 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
- Full NIP compliance validation
- 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-gmp(bignum arithmetic for the pure-PHP secp256k1 signing and ECDH path; 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(search-filter matching on untrusted event content andEventContent::getLength)ext-sodium(NIP-44 and NIP-49 AEAD,sodium_memzero)paragonie/ecc(pure-PHP secp256k1 fallback)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 by NIP-49 (unconditionally) and by theSecp256k1Signer::create()/Secp256k1Ecdh::create()factories (for thelibsecp256k1probe). Consumers who do not use NIP-49 and who construct the adapters directly withnew Secp256k1Signer(null, ...)/new Secp256k1Ecdh(null)can run withoutext-ffiat all and stay on the pure-PHP path.
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. -
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
A RumourFactory builds an unsigned Rumour; signing it mints a signed Event:
use Innis\Nostr\Core\Domain\Factory\RumourFactory; $rumour = RumourFactory::createTextNote( $keyPair->getPublicKey(), 'Hello Nostr!' ); $signedEvent = $rumour->sign($keyPair, $signatureService); $signedEvent->verify($signatureService); // bool
NIP-44 Encryption
Deriving a conversation key needs an ECDH service. Secp256k1Ecdh::create() follows the same FFI-or-fallback pattern as the signature adapter:
use Innis\Nostr\Core\Domain\ValueObject\Identity\ConversationKey; use Innis\Nostr\Core\Infrastructure\Crypto\Nip44Cipher; use Innis\Nostr\Core\Infrastructure\Crypto\Secp256k1Ecdh; $ecdhService = Secp256k1Ecdh::create(); $conversationKey = ConversationKey::derive( $senderPrivateKey, $recipientPublicKey, $ecdhService, ); $encryption = new Nip44Cipher(); $ciphertext = $encryption->encrypt('Hello in private', $conversationKey); $plaintext = $encryption->decrypt($ciphertext, $conversationKey);
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.
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\Service\JsonMessageDeserialiser; use Innis\Nostr\Core\Domain\ValueObject\Protocol\Message\Client\EventMessage; $deserialiser = new JsonMessageDeserialiser(); $eventMessage = new EventMessage($signedEvent); $json = $eventMessage->toJson(); $deserialised = $deserialiser->deserialiseClientMessage($json);
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, so there is exactly one way to produce a NIP-19 string.
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.
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:
$codec = new Nip19Codec(); $entity = $codec->decodeComplexEntity($input); // ?Nip19EntityInterface $publicKey = match (true) { $entity instanceof Npub, $entity instanceof Nprofile => $entity->getPublicKey(), default => null, };
When you already know the prefix, call that leaf's own tryFromBech32 instead. For the narrower "give me whatever event this string points at" question, parseEventReference accepts a raw hex id, a note, an nevent, or an naddr and returns EventId|EventCoordinate|null. Rationale for the split — and for Nip19EntityType gaining a Note case so five entities map to five cases — is in ADR-0060; the TLV codec is an internal value object per ADR-0059.
Verifying Zap Receipts (NIP-57)
ZapReceipt::tryFromEvent is a parser and authenticates nothing — its getSenderPubkey() is read from the unverified description blob and is attacker-chosen until checked. ZapReceiptVerifier applies NIP-57 Appendix F, returning null on success or a ZapReceiptVerificationFailure case naming what failed:
use Innis\Nostr\Core\Domain\Service\ZapReceiptVerifier; $verifier = new ZapReceiptVerifier($signatureService); $failure = $verifier->verify($receiptEvent, $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-0062 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\PrivateKey; use Innis\Nostr\Core\Infrastructure\Crypto\Nip49Cipher; $adapter = Nip49Cipher::create(); $privateKey = PrivateKey::generate(); $ncryptsec = $adapter->encrypt( $privateKey, static fn (): string => readPasswordFromUser(), logN: 16, keySecurity: KeySecurityByte::ClientSideOnly, ); $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, then unwrap it
The examples/ directory is covered by PHPStan and php-cs-fixer in CI, like src and tests.
Supported NIPs
| NIP | Description | Support |
|---|---|---|
| NIP-01 | Basic protocol flow | Event creation, signing, verification, serialisation |
| 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 |
| NIP-10 | Reply conventions | Reply chain analysis with root/reply/mention markers |
| NIP-11 | Relay information | Relay metadata fetching and parsing |
| NIP-17 | Private direct messages | Kind 14 with NIP-44 encryption and gift wrap (kind 1059) |
| NIP-18 | Reposts | Kind 6/16 with embedded event extraction and quote detection |
| NIP-19 | Bech32 encoding | npub, nsec, note, nprofile, nevent, naddr — each entity a distinct value object behind Nip19EntityInterface, encoding itself 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 |
| NIP-23 | Long-form content | Kind 30023 as parameterised replaceable events |
| NIP-25 | Reactions | Kind 7 event support |
| NIP-28 | Public chat | Kind 40-44 channel event types |
| NIP-40 | Expiration | Event expiration detection via isExpired() |
| NIP-42 | Authentication | AUTH message handling and challenge detection |
| NIP-44 | Encrypted payloads | NIP-44 v2 encrypt/decrypt with ECDH, ChaCha20, HMAC-SHA256 |
| NIP-45 | Counting | COUNT relay message support |
| NIP-49 | Private key encryption | Password-encrypted ncryptsec with scrypt + XChaCha20-Poly1305 |
| NIP-50 | Search | Search filter support |
| NIP-51 | Lists | All standard list kinds (10000-10102) and set kinds (30000-39092) |
| NIP-57 | Lightning zaps | Zap request/receipt parsing, BOLT-11 amount extraction, and Appendix F receipt verification via ZapReceiptVerifier (parsing alone authenticates nothing) |
| NIP-61 | Nutzaps | Kind 9321 cashu proof parsing and amount extraction |
| NIP-70 | Protected events | Protected event detection via isProtected() |
| NIP-98 | HTTP auth | Kind 27235 validation: signature, URL, method, payload hash, timestamp tolerance |
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, web pages, and more) together with the replaceable / ephemeral / parameterised-replaceable range boundaries, so consumers can classify kinds 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 for NIP-44 conversation keys
To install the native library:
# Ubuntu/Debian 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.
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, 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; use RuntimeException; $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, ADR-0025, and ADR-0057.
Architecture
This package follows Clean Architecture principles with strict layer separation:
- Domain Layer: Pure business logic, immutable entities and value objects (cryptographic library is the sole external dependency, used directly by identity value objects)
- Application Layer: Port interfaces for external service integration
- Infrastructure Layer: Implementations of the domain and application interfaces that reach external technology, grouped by concern (
Crypto/,Http/,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) |
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
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. Two filter sets that select the same events hash to the same digest regardless of input ordering, and 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.
$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.