activeledger / sdk
PHP SDK for Activeledger, with post-quantum identity support (ML-DSA-65)
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
- paragonie/pqcrypto_compat: ^0.3
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- ext-intl: NFKD normalisation for non-ASCII BIP-39 passphrases; ASCII is unaffected
- ext-pqcrypto: Faster ML-DSA signing; pqcrypto_compat uses it automatically when present
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-28 10:21:14 UTC
README
Activeledger - PHP SDK
Build, sign and submit Activeledger transactions from PHP, with post-quantum identities.
- ML-DSA-65 post-quantum identities, verified against the ledger's published cross-language vectors
- Canonical JSON that reproduces the exact bytes the ledger signs
- Transaction builder, client and server-sent event subscriptions
- Pure PHP — no extension required, no native library to install
Read this first: the seed is the portable private key
A private key here is a 32-byte seed, not the 4032-byte encoding the other
SDKs export. paragonie/pqcrypto_compat implements FIPS 204 key generation
from a seed, but not skEncode/skDecode, so the 4032-byte form cannot be
loaded here and cannot be turned back into the seed it came from.
That used to make an identity unmovable in either direction. It no longer does — every Activeledger SDK can now import a seed — but the direction matters:
| Works | |
|---|---|
| Verify any ledger signature | ✅ public keys are the standard 1952-byte encoding |
| Onboard an identity created here | ✅ the ledger sees a normal ml-dsa-65 identity |
| Sign transactions | ✅ standard 3309-byte signatures, hedged |
| Reuse an identity created here, in PHP | ✅ store seedBase64() |
| Take a PHP identity to another SDK | ✅ give it the seed, via that SDK's fromSeed |
| Recover the same identity in both from one phrase | ✅ see Recovery phrases |
| Load another SDK's 4032-byte private key here | ❌ throws, by design |
So share the seed, never the 4032-byte key. The same 32 bytes derive an identical 1952-byte public key in every SDK — verified against the JavaScript implementation byte for byte.
Passing a 4032-byte key throws immediately with an explanation. That guard exists because the library underneath does not reject it: it accepts any string as seed material, derives a completely unrelated identity, and signs happily. Those signatures are then rejected by the ledger as 1220 "Signature Incorrect" — a message that points nowhere near the cause.
Seeds and recovery phrases
From a seed
use Activeledger\KeyPair; use Activeledger\Secp256k1KeyPair; $pq = KeyPair::fromSeed($seed); // 32 bytes $ec = Secp256k1KeyPair::fromSeed($seed); // 32 bytes
A seed of the wrong length is refused, not padded — a padded seed is a different identity, not a malformed one.
For secp256k1 the seed is the private scalar, so it has to be a valid
one. A seed of zero, or one at or above the curve order, is refused rather
than reduced mod n: reducing produces a perfectly functional key belonging
to a different identity, and nothing downstream ever reports a problem.
Recovery phrases
$pq = KeyPair::fromPhrase($phrase); // ml-dsa-65 $ec = Secp256k1KeyPair::fromPhrase($phrase); // secp256k1 $withPassphrase = KeyPair::fromPhrase($phrase, "...");
One phrase backs both identity types at once, because each derives its own seed:
| Type | Seed from the BIP-39 seed S |
|---|---|
ml-dsa-65 |
HKDF-SHA512(S, salt="", info="activeledger-seed-v1:ml-dsa-65", 32) |
falcon-512 |
HKDF-SHA512(S, salt="", info="activeledger-seed-v1:falcon-512", 48) |
secp256k1 |
HMAC-SHA512("Bitcoin seed", S)[0..32] |
secp256k1 deliberately does not use HKDF: the JavaScript SDK shipped that
derivation before the post-quantum types existed, so phrases are already in
use, and changing it would hand those users a different key for a phrase that
used to work. Secp256k1KeyPair::fromLegacyPhrase() recovers a phrase made
by the older @activeledger/sdk-bip39 package — for recovery only, never for
new keys.
The phrase is validated, wordlist and checksum both. A mistyped phrase that is not checked does not fail; it derives a perfectly valid key for an identity nobody owns, and the only symptom is the ledger not recognising it.
ext-intl is optional and only affects non-ASCII passphrases, which BIP-39
requires to be NFKD-normalised.
Key types
| Key type | Wire string | Public | Private | Signature | Encoding |
|---|---|---|---|---|---|
| ML-DSA-65 | ml-dsa-65 |
1952 | 32-byte seed | 3309 | base64 |
| secp256k1 | secp256k1 |
33 or 65 | 32 | ~70-72, variable | 0x hex |
Use secp256k1 unless the identity must outlive a cryptographically relevant quantum computer: roughly 22x smaller per transaction, and every byte is stored on the ledger permanently and replicated to every node. It also works with hardware wallets and HSMs, is the only way to sign for an identity created before post-quantum support — and, unlike ML-DSA here, its private keys are portable to and from every other Activeledger SDK.
use Activeledger\Secp256k1KeyPair; $key = Secp256k1KeyPair::generate(); // compressed $full = Secp256k1KeyPair::generate(compressed: false); // uncompressed $key->publicKey(); // "0x02a1b2..." - give this to the ledger $key->privateKey(); // store this; it works in any SDK $restored = Secp256k1KeyPair::fromKeys($key->publicKey(), $key->privateKey()); $verifier = Secp256k1KeyPair::fromPublicKey($key->publicKey());
secp256k1 uses ext-openssl, which is enabled in virtually every PHP build
and is the same implementation the ledger verifies with — so interop is
structural rather than hopeful. No Composer package, and no ext-gmp (which
simplito/elliptic-php requires outright).
Encoded nothing like the post-quantum keys
- Keys are
0x-prefixed hex, not base64. The prefix is required rather than tolerated, because hex without it can decode as base64 into plausible-looking bytes of the wrong length. - Public keys have two valid lengths, 33 compressed and 65 uncompressed, and the ledger accepts both. A length and a SEC1 point prefix that disagree are rejected by name.
- Private scalars are always 32 bytes, left-padded.
- Signatures are SHA-256 → ECDSA → DER, and DER length varies.
low-S, in both directions
Signing always emits low-S. OpenSSL does not normalise, so this SDK folds
S itself. Not for the ledger, which accepts either, but for @noble/curves —
the reference for the JavaScript side — and for libsecp256k1 and Rust's
k256, all of which reject high-S by default. A signer emitting high-S
roughly half the time fails against those roughly half the time, which reads
as flakiness rather than as a signature format problem.
Verification accepts high-S, because the ledger produces it freely. Rejecting those would fail on roughly half of all valid signatures.
One difference from the other SDKs
Signing is not deterministic here. OpenSSL uses a random k and offers no way to inject one, so unlike the JavaScript, JVM, C#, Go and Rust SDKs this one cannot reproduce the published RFC 6979 reference bytes — signing the same message twice gives different bytes.
That costs a test, not correctness: everything it emits is a valid, canonical, low-S signature that the ledger and every other SDK accept, and the suite verifies the published deterministic signatures even though it cannot reproduce them. Implementing RFC 6979 in PHP would mean hand-rolling 256-bit modular arithmetic and point multiplication, which is a far worse trade.
Install
composer require activeledger/sdk
Verified: resolves activeledger/sdk (v2.3.0) from Packagist with
paragonie/pqcrypto_compat and paragonie/sodium_compat, on PHP 8.5.
Quick start
use Activeledger\Client; use Activeledger\KeyPair; use Activeledger\Transaction; $client = new Client('http://localhost:5260'); // A post-quantum identity $key = KeyPair::generate(); $identity = $client->onboard($key); echo $identity->streamId; // Store this to reuse the identity later. It is all you need, and it is // the ONLY thing that will restore it. file_put_contents('identity.seed', $key->seedBase64()); // A transaction signed by it $tx = Transaction::builder() ->namespace('default') ->contract('mycontract') ->input($identity->streamId, $key, ['amount' => 100]) ->output('someotherstream') ->build(); $response = $client->submit($tx); if (!$response->committed()) { // NOT the HTTP status. See "A rejected transaction is an HTTP 200" below. print_r($response->errors()); }
Restoring that identity on a later request:
$key = KeyPair::fromSeedBase64(file_get_contents('identity.seed'));
Keys
$key = KeyPair::generate(); $key->publicKeyBase64(); // 1952 bytes, exactly as the ledger stores it $key->seedBase64(); // 32 bytes - store this $key->canSign(); // true // Verification only $verifier = KeyPair::fromPublicKeyBase64($publicKey); $verifier->verify($message, $signature); // bool
Signing is hedged, not deterministic: fresh entropy goes into every signature, so signing the same message twice produces different bytes. This matches the reference implementation. Never compare signatures for equality — verify them.
Performance, measured on a pure-PHP 8.4 build with no extension: key generation ~28 ms, signing ~68 ms, verification ~27 ms.
Transactions
$tx = Transaction::builder() ->namespace('default') ->contract('mycontract') ->entry('transfer') // optional ->input($streamId, $key, ['amount' => 100]) ->output($otherStream) // optional ->readonly('label', $someStreamId) // optional, see "Reading state" ->build();
Every signer signs the same bytes: the canonical form of $tx. $sigs is
keyed by input stream id.
Onboarding differs in two ways that catch every port, so it has its own
constructor — $selfsign is true, and $sigs is keyed by the $i label
because no stream exists yet:
$tx = Transaction::onboard($key);
To inspect exactly what was signed — the fastest way to diagnose a rejected signature:
$tx->signedBytes(); // the $tx object alone $tx->toJson(); // the full envelope, as submitted
Reading state
There is no read API. A node's storage service listens only on that node's own
host, so reading state is a transaction like any other: name the streams in
$r, and the contract hands values back with returnToRemote.
$tx = Transaction::builder() ->namespace('default') ->contract('mycontract') ->entry('read') ->input($identity->streamId, $key) ->readonly('target', $streamToRead) ->build(); foreach ($client->submit($tx)->responses() as $value) { echo $value['balance']; }
$response->newStreams() gives the ids of any streams the transaction created.
Events (SSE) - deprecated
Client::subscribeToActivity, subscribeToContractEvents and subscribe are deprecated and will be removed in the next major version.
Events are no longer served by ActiveCore, which is itself deprecated and
should not be used. A node serves contract events from its own storage
service at http://localhost:<storage port>/activeledgerevents/events, and
that service must never be reachable beyond the node's host - so a client
SDK has nothing it should connect to.
To react to events, run your own server-sent events listener on the node's
host and relay what your application needs through your own backend. Each
event is an SSE frame whose id is <milliseconds>-<counter>,<umid> and
whose data is {"name", "data", "phase", "contract"}.