Search by

PHP SDK for Activeledger, with post-quantum identity support (ML-DSA-65)

v2.3.0 2026-09-21 07:21 UTC

This package is auto-updated.

Last update: 2026-09-25 12:53:02 UTC


README

Activeledger

Packagist PHP licence

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)

Event streams are served by Activecore, which is a separate service on its own port — not a path on the node. Pointing a subscription at a node returns 403, so the URL is supplied separately and subscribing without it throws a message naming the missing URL rather than quietly producing an empty stream.

use Activeledger\LedgerEvent;

$client = new Client('http://localhost:5260', 'http://localhost:5261');

// Every stream change on the ledger. Return false from the callback to stop.
$client->subscribeToActivity(function (LedgerEvent $event): bool {
    echo $event->data;
    return true;
});

// Changes to one stream
$client->subscribeToActivity($callback, $streamId);

// Events a contract emitted: all, by contract, or one named event
$client->subscribeToContractEvents($callback);
$client->subscribeToContractEvents($callback, 'mycontract');
$client->subscribeToContractEvents($callback, 'mycontract', 'transfer');

Activity and contract events are different feeds. Activity fires for stream changes, so it is what an ordinary transaction produces. Contract events carry only what a contract explicitly emitted — subscribe there for a transaction that emits nothing and you will correctly receive nothing, which looks exactly like a broken subscription.

Each LedgerEvent has data, plus name and id when the server sent them, and json() to decode the payload. Multiple data: lines join with newlines, and : heartbeat comments are ignored rather than delivered as empty events.

Subscribing blocks. Return false from the callback to close the connection, or pass a timeout in seconds to subscribe() — a timeout expiring is a normal end to a subscription, not an error.

Signing elsewhere

Signer is all the SDK needs, so keys can live in an HSM, a remote signing service or a user's wallet — and it is also the way to sign with a key this SDK cannot load itself:

use Activeledger\KeyType;
use Activeledger\Signer;

final class HsmSigner implements Signer
{
    public function keyType(): KeyType
    {
        return KeyType::MlDsa65;
    }

    public function publicKeyBase64(): string
    {
        // ...
    }

    public function sign(string $message): string
    {
        // ...
    }
}

sign() receives the canonical bytes of $tx and returns a raw signature; the SDK base64-encodes it.

Things that will bite you

A rejected transaction is an HTTP 200. The ledger answers 200 and reports the problem in the body. Check $response->committed() — code that treats the HTTP status as success will report commits that never happened, and will keep doing so until something downstream notices the data is missing.

Every signature problem is reported as 1220 "Signature Incorrect". A wrong key type, a missing type, wrong key material and genuinely bad bytes all produce that one message. $tx->signedBytes() is usually the quickest way in.

A missing type defaults to rsa. The ledger then tries RSA verification against whatever it was given. This SDK always sends the type explicitly.

Namespaces are claimed permanently. A test that registers a fixed namespace passes once and fails every re-run against the same network, which reads like a regression and is not one.

Consensus is a majority. When a submission returns, most nodes have committed and the rest may still be writing. Reading immediately from a specific node is a race.

Canonical JSON

Signatures cover the exact bytes of JSON.stringify($tx) encoded as UTF-8 — no hash prefix, no length prefix, no domain separator and no key sorting. A signature over bytes that differ by a single escape is invalid.

PHP's json_encode cannot produce these bytes. By default it escapes forward slashes as \/ — and every base64 public key is full of them — and non-ASCII as \uXXXX. So this SDK has its own serialiser:

use Activeledger\CanonicalJson;

$json  = CanonicalJson::encode(['zebra' => 1, 'alpha' => 'text']);
$bytes = CanonicalJson::bytes(['zebra' => 1, 'alpha' => 'text']);

PHP arrays preserve insertion order, and that order is what gets signed.

One PHP-specific trap: [] is both an empty array and an empty object, and $o in a transaction must serialise as {}. Use CanonicalJson::object() where an object is required — the transaction builder already does.

CanonicalJson::encode(['$o' => []]);                        // {"$o":[]}  wrong
CanonicalJson::encode(['$o' => CanonicalJson::object()]);   // {"$o":{}}  right

Numbers follow JavaScript: whole values print without a fractional part, so 1.0 serialises as 1. NAN and INF are refused rather than silently written as null.

Testing

composer install
vendor/bin/phpunit --testsuite unit

The live-network tests need a ledger. Start one from an activeledger checkout:

npm run test:network:serve

then run with the URLs it prints:

AL_NODES=http://127.0.0.1:5510,http://127.0.0.1:5520 \
AL_STORAGE=http://127.0.0.1:5509,http://127.0.0.1:5519 \
vendor/bin/phpunit --testsuite live

They onboard real post-quantum identities, verify what the ledger actually recorded on every node, confirm a tampered payload is rejected, check that an identity restored from its seed still controls its stream, and open a real event stream.

Migrating from 1.x

Version 2 is a rewrite. The old lowercase classes are gone and the package is PSR-4 under Activeledger\.

1.x 2.x
activeledger\key Activeledger\KeyPair
activeledger\transaction Activeledger\Transaction::builder()
activeledger\connection Activeledger\Client

RSA and EC key generation are not carried over; identities created by this SDK are post-quantum. Signer is available for anything else.

Licence

MIT