PHPackages                             innis/nostr-core - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. innis/nostr-core

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

innis/nostr-core
================

Core domain entities and services for Nostr protocol implementation

v0.5.2(1mo ago)1128↓11.1%4MITPHPPHP ^8.4CI passing

Since Mar 23Pushed 2w agoCompare

[ Source](https://github.com/johninnis/nostr-core)[ Packagist](https://packagist.org/packages/innis/nostr-core)[ RSS](/packages/innis-nostr-core/feed)WikiDiscussions master Synced 4w ago

READMEChangelogDependencies (49)Versions (40)Used By (4)

Nostr Core Package
==================

[](#nostr-core-package)

[![CI](https://github.com/johninnis/nostr-core/actions/workflows/ci.yml/badge.svg)](https://github.com/johninnis/nostr-core/actions/workflows/ci.yml)

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](docs/adr/0019-domain-first-organisation-cryptography-only-domain-dependency.md) for the organising rationale and [ADR-0045](docs/adr/0045-rumour-is-the-unsigned-event-value-object-event-composes-it.md) 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](#security) and [SECURITY.md](SECURITY.md#security-properties).

Features
--------

[](#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](#security))
- Bech32 *and* bech32m encoding/decoding via a single `Bech32Codec`(NIP-19 prefixes plus BIP-350 bech32m variants), selected through the `Bech32Variant` enum
- 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
------------

[](#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 by `paragonie/ecc`, so the package cannot install without it even on a host that always uses the native `libsecp256k1` path)
- `ext-intl` (NFKC password normalisation in NIP-49)
- `ext-mbstring` (search-filter matching on untrusted event content and `EventContent::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, which `ext-sodium` does not expose)

Declared under `suggest` in `composer.json`:

- `ext-ffi` is needed by NIP-49 (unconditionally) and by the `Secp256k1Signer::create()` / `Secp256k1Ecdh::create()` factories (for the `libsecp256k1` probe). Consumers who do not use NIP-49 and who construct the adapters directly with `new Secp256k1Signer(null, ...)` / `new Secp256k1Ecdh(null)` can run without `ext-ffi` at all and stay on the pure-PHP path.

### Optional system libraries

[](#optional-system-libraries)

- `libsecp256k1` — when present, Schnorr signing, verification, public-key derivation, and NIP-44 ECDH use the native C library (reached via `ext-ffi`) for significantly faster performance. Without it, the library falls back to a pure-PHP implementation via `paragonie/ecc` automatically. 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](#security).
- `libsodium` — required by NIP-49 scrypt derivation, which calls `crypto_pwhash_scryptsalsa208sha256_ll` through `ext-ffi`. Typically already installed wherever `ext-sodium` is.

Installation
------------

[](#installation)

```
composer require innis/nostr-core
```

Quick Start
-----------

[](#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](#security) before running a server-side or long-lived signer on the pure-PHP path.

### Key Generation

[](#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

[](#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

[](#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](docs/adr/0014-nip44cipher-has-no-public-encryptwithnonce.md).

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

[](#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);
```

### Password-Encrypted Private Keys (NIP-49)

[](#password-encrypted-private-keys-nip-49)

The NIP-49 adapter takes the password as a `Closure(): string` rather than a raw string. The adapter invokes the closure exactly once, `sodium_memzero`s the revealed password before the method returns, and the caller never has to maintain a password binding in its own scope:

```
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());
```

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](docs/adr/0041-nip49-adapters-probe-libsodium-in-create-not-the-constructor.md) and [ADR-0039](docs/adr/0039-nip49-scrypt-requires-ffi-with-no-pure-php-fallback.md).

### Secret Key Lifecycle

[](#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_memzero`s them when it returns; see [ADR-0028](docs/adr/0028-secretkeymaterial-expose-hands-a-detached-copy-so-the-wipe-is-effective.md):

```
$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](docs/adr/0015-zero-is-a-contract-not-a-guarantee-via-destruction.md) for why the destructor is not relied upon.

Examples
--------

[](#examples)

Runnable scripts live in [`examples/`](examples/); run one with `php examples/.php`:

- [`sign_and_verify.php`](examples/sign_and_verify.php) — generate a key pair, create and sign a text note, verify it
- [`nip44_encrypt_decrypt.php`](examples/nip44_encrypt_decrypt.php) — derive a NIP-44 conversation key via ECDH and encrypt/decrypt a message
- [`nip49_password_encrypt.php`](examples/nip49_password_encrypt.php) — encrypt a private key under a password to an `ncryptsec` and recover it (requires `ext-ffi` and libsodium)
- [`giftwrap_direct_message.php`](examples/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
--------------

[](#supported-nips)

NIPDescriptionSupport[NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md)Basic protocol flowEvent creation, signing, verification, serialisation[NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md)Follow listKind 3 with contact list tags[NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md)Encrypted direct messagesKind 4 with recipient validation; `Nip04Cipher` for AES-256-CBC encrypt/decrypt over a 32-byte ECDH shared secret[NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md)DNS-based identityIdentifier parsing and HTTP verification[NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)Event deletionKind 5 with deletion tag validation and `isDeletion()` detection[NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md)Reply conventionsReply chain analysis with root/reply/mention markers[NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md)Relay informationRelay metadata fetching and parsing[NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md)Private direct messagesKind 14 with NIP-44 encryption and gift wrap (kind 1059)[NIP-18](https://github.com/nostr-protocol/nips/blob/master/18.md)RepostsKind 6/16 with embedded event extraction and quote detection[NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md)Bech32 encodingnpub, nsec, note, nprofile, nevent, naddr encoding/decoding; `Bech32Codec` also supports the BIP-350 bech32m variant for non-NIP consumers (e.g. FROSTR `bfgroup1…` / `bfshare1…` / `bfonboard1…`) via the `Bech32Variant` enum[NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)CommentsKind 1111 with root/parent kind tags and reply chain analysis[NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md)Long-form contentKind 30023 as parameterised replaceable events[NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md)ReactionsKind 7 event support[NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md)Public chatKind 40-44 channel event types[NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)ExpirationEvent expiration detection via `isExpired()`[NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md)AuthenticationAUTH message handling and challenge detection[NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)Encrypted payloadsNIP-44 v2 encrypt/decrypt with ECDH, ChaCha20, HMAC-SHA256[NIP-45](https://github.com/nostr-protocol/nips/blob/master/45.md)CountingCOUNT relay message support[NIP-49](https://github.com/nostr-protocol/nips/blob/master/49.md)Private key encryptionPassword-encrypted `ncryptsec` with scrypt + XChaCha20-Poly1305[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md)SearchSearch filter support[NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)ListsAll standard list kinds (10000-10102) and set kinds (30000-39092)[NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md)Lightning zapsZap request/receipt parsing, BOLT-11 amount extraction[NIP-61](https://github.com/nostr-protocol/nips/blob/master/61.md)NutzapsKind 9321 cashu proof parsing and amount extraction[NIP-70](https://github.com/nostr-protocol/nips/blob/master/70.md)Protected eventsProtected event detection via `isProtected()`[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)HTTP authKind 27235 validation: signature, URL, method, payload hash, timestamp toleranceBeyond 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
-----------

[](#performance)

### Native FFI Acceleration

[](#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 sign
- `verify` — BIP340 Schnorr verify
- `derivePublicKey` — secret to 32-byte x-only pubkey
- `computeSharedX` — 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
--------

[](#security)

See [SECURITY.md](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. 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](SECURITY.md#security-properties) and [ADR-0025](docs/adr/0025-secp256k1-keeps-a-native-ffi-path-and-a-pure-php-fallback.md).

Architecture
------------

[](#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
----------------------

[](#architecture-decisions)

Design rationale lives in [`docs/adr/`](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
------------

[](#dependencies)

PackagePurpose`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
-------

[](#testing)

```
# Full suite: Unit + Integration + Compliance + PHPStan (ship gate)
composer test

# Unit suite only (fast inner loop; skips compliance property fuzz)
composer test-unit

# PHPStan analysis (level 9)
composer analyse

# Fix code style
composer fix-style
```

Filter-set hash
---------------

[](#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](docs/adr/0020-filterhasher-canonicalises-to-ascii-safe-json-for-cross-language-parity.md); the conformance anchors that lock the two runtimes together are asserted in both packages' test suites.

License
-------

[](#license)

MIT License. See LICENSE file for details.

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance96

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community14

Small or concentrated contributor base

Maturity54

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

39

Last Release

33d ago

PHP version history (3 changes)v0.1.0PHP ^8.1

v0.1.1PHP ^8.3

v0.4.0PHP ^8.4

### Community

Maintainers

![](https://www.gravatar.com/avatar/d2ca81e761ec3b2d4ce98ac59cd4394bb4d70ebf3d1e620ff154ffcfc7a34bfc?d=identicon)[innis](/maintainers/innis)

---

Top Contributors

[![johninnis](https://avatars.githubusercontent.com/u/242370111?v=4)](https://github.com/johninnis "johninnis (223 commits)")

---

Tags

protocolcoredomainnostr

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/innis-nostr-core/health.svg)

```
[![Health](https://phpackages.com/badges/innis-nostr-core/health.svg)](https://phpackages.com/packages/innis-nostr-core)
```

###  Alternatives

[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k39.6k](/packages/matomo-matomo)[ecotone/ecotone

Enterprise architecture layer for Laravel and Symfony — CQRS, Event Sourcing, Durable Workflows (Sagas, Orchestrators), Projections, and Outbox messaging via PHP attributes.

568591.1k61](/packages/ecotone-ecotone)[civicrm/civicrm-core

Open source constituent relationship management for non-profits, NGOs and advocacy organizations.

762297.9k49](/packages/civicrm-civicrm-core)[illuminate/broadcasting

The Illuminate Broadcasting package.

7127.4M231](/packages/illuminate-broadcasting)[logiscape/mcp-sdk-php

Model Context Protocol SDK for PHP

367137.2k15](/packages/logiscape-mcp-sdk-php)[symfony/ai-platform

PHP library for interacting with AI platform provider.

521.6M361](/packages/symfony-ai-platform)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
