amashukov/ton-crypto-php
Correct TON mnemonic and Ed25519 keypair derivation in pure PHP. Uses PBKDF2-HMAC-SHA512 with the "TON default seed" salt, not BIP-39, so 24-word phrases produce matching TON wallets. Sign and verify via libsodium, zero composer dependencies.
composer require amashukov/ton-crypto-php
Check the two non-negotiable requirements before you write a line of code:
php -v # must be >= 8.3
php -m | grep -iE 'sodium|hash' # ext-sodium + ext-hash
ext-sodium is the usual failure. Laravel Sail, Herd and Valet ship it; slim php:8.3-fpm images and many distro packages do not. On Debian/Ubuntu that's apt install php8.3-sodium; in Docker it's docker-php-ext-install sodium (needs libsodium-dev). Add the same check to CI before you add the dependency to a real project — you do not want to discover it during a deploy.
Where to look first. The whole public API is four entry points, readable in a minute:
Mnemonic::toSeed(string $phrase, ?string $password = null): string — 32 raw bytesMnemonic::toKeyPair(string $phrase, ?string $password = null): KeyPairKeyPair::generate() / KeyPair::fromSeed(string $seed32)$kp->sign(string $payload): string (64 raw bytes) / $kp->verify(string $payload, string $sig): boolPlus the constants you'll assert against in tests: KeyPair::SEED_BYTES (32), PUBLIC_KEY_BYTES (32), SECRET_KEY_BYTES (64), SIGNATURE_BYTES (64), Mnemonic::DEFAULT_SALT, Mnemonic::PBKDF2_ITERATIONS, Mnemonic::PBKDF2_OUTPUT_BYTES. The classes are final readonly with static entry points — there is no container binding, no config file, no facade. Everything is on you.
Be clear about what this package is not. It produces keys, not addresses. A TON address is a function of the wallet contract code hash plus the public key, which lives in amashukov/ton-wallet-php; chain access lives in amashukov/toncenter-client-php. Budget for the trio, not the leaf.
First use case — provision a wallet without ever touching a phrase:
// app/Services/Ton/TonKeyService.php
$kp = KeyPair::generate();
$user->tonWallets()->create([
'seed_ciphertext' => Crypt::encryptString(base64_encode($kp->seed())), // confirm accessor name on first use
'public_key_b64' => base64_encode($kp->publicKey()),
]);
The server generates and holds key material; the user never sees a mnemonic. This is the pattern that gets you to production fastest and sidesteps the validation problems described below.
Wrap it. This is not optional. With final readonly classes and static methods you have no seam to mock, so define your own interface and bind it:
// app/Contracts/TonKeyService.php
interface TonKeyService
{
public function generate(): TonKeyMaterial;
public function fromMnemonic(string $phrase, ?string $pass = null): TonKeyMaterial;
public function sign(string $payload, string $seedCipher): string;
public function verify(string $payload, string $sig, string $pubkeyB64): bool;
}
// app/Providers/AppServiceProvider.php
$this->app->bind(TonKeyService::class, SodiumTonKeyService::class);
Given the package's maturity (see below), this is the difference between "swap a binding" and "grep the codebase" if you ever outgrow it.
Persist one secret per wallet, not two. Derivation is deterministic, so KeyPair::fromSeed($seed) reproduces the public key and every signature exactly. Store the 32-byte seed, encrypted, and the public key in the clear for lookups:
Schema::create('ton_wallets', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained();
$t->text('seed_ciphertext'); // text, not binary — Crypt output is a base64 JSON envelope
$t->string('public_key_b64', 44);
$t->string('address')->nullable(); // filled by ton-wallet-php
$t->timestamps();
$t->unique(['public_key_b64']);
});
The 'encrypted' model cast works too, but the column still has to be text.
Sign/verify as an auth flow. A detached Ed25519 signature over a nonce is exactly what TON Connect hands you, so login is a two-endpoint dance:
// 1) issue
$nonce = Str::random(32);
Cache::put("ton:nonce:{$nonce}", $user->id, now()->addMinutes(5));
return ['nonce' => $nonce];
// 2) verify
$userId = Cache::pull("ton:nonce:{$request->input('nonce')}");
abort_unless($userId, 422, 'Stale nonce');
$ok = $ton->verify(
$request->input('nonce'),
base64_decode($request->input('signature'), true),
$request->input('publicKey') // base64 as-is; decode inside the service
);
abort_unless($ok, 403);
Nonce in the cache with a TTL, single-use via pull(). Sign the nonce string, not a JSON blob — canonicalisation bugs are the top cause of "signature doesn't verify" tickets.
Derive in a job, never in a hot path. PBKDF2-HMAC-SHA512 at 100 000 iterations is tens of milliseconds of pure PHP. Fine for a login or a one-off import; catastrophic inside a foreach over a CSV or an N+1. Queue bulk imports, and rate-limit any endpoint that accepts a phrase — it's a trivially cheap CPU DoS otherwise.
Pin fixtures in tests. generate() calls random_bytes(), so never assert on it:
$phrase = 'abandon ... abandon art'; // README's 24-word example
$kp = Mnemonic::toKeyPair($phrase);
$this->assertSame(32, strlen(Mnemonic::toSeed($phrase)));
$this->assertTrue($kp->verify('hello', $kp->sign('hello')));
$this->assertFalse($kp->verify('hello', str_repeat("\0", 64)));
// deterministic key material without random_bytes()
$fixed = KeyPair::fromSeed(str_repeat("\x01", 32));
The test that actually matters is asserting a known address once ton-wallet-php is wired — that is the only assertion that catches a wrong derivation end-to-end.
Normalise at the form layer. The package already trims and collapses tab/newline runs, so a phrase pasted from a paper backup works. Do the rest before handing off: strtolower(trim(preg_replace('/\s+/u', ' ', $raw))). Mixed case and non-breaking spaces are the classic "wrong address" bug reports.
It is emphatically not BIP-39. Salt is TON default seed (not "mnemonic"), 100 000 iterations of PBKDF2-HMAC-SHA512, plus an extra hash_hmac('sha512', $password, $phrase, true) pass feeding the entropy in. Never route a TON phrase through bitwasp/bitcoin or any BIP-39 helper: you'll get a perfectly valid-looking keypair and a wallet the user's TON app won't recognise. The reciprocity also holds — a seed from this package is TON-only.
The $password argument is a second factor, not a checksum. Non-null changes the derived key completely and irreversibly. TON wallets with a passphrase are rare. If your UI has any "password" field on a mnemonic form, make sure it can never be wired to this parameter by accident — that is the fastest route to unrecoverable wallets, and it will look like a working feature until someone tries to restore.
Derivation is one-way — check this before you design onboarding. The README documents phrase → seed and seed → keypair. It does not document mnemonic generation or seed → phrase. If your product needs to show a user 24 words to write on paper, verify that surface exists in the package before promising it; otherwise back up the 32-byte seed (base64, encrypted, exported through a step-up auth flow) and design the recovery UX around that.
Assume mnemonic validation is thin. Validate yourself. The README describes no wordlist check and no checksum verification on toKeyPair() — an arbitrary string plausibly yields some 32-byte seed rather than throwing. Before accepting a phrase from a user, enforce: exactly 24 words, every word in the TON/BIP-39 English wordlist, and the checksum valid. Otherwise a single typo silently mints a wallet nobody can ever restore, and you find out months later in a support ticket.
verify() returns false, it never throws. Write if (! $kp->verify(...)), and never use try/catch as your only guard. Any signature that isn't exactly 64 bytes returns false — which means a hex-encoded signature, a base64:-prefixed string, or a URL-safe-base64 variant looks identical to a wrong key. Decode before the call and reject on decode failure at the boundary.
Binary in, base64 at the edges. 32- and 64-byte blobs will be mangled by json_encode (invalid UTF-8 → null, or an exception from Laravel's response). Raw bytes inside services, base64 in every request/response payload — no exceptions.
Keep seed material out of logs, dumps, traces and queues. A KeyPair in a dd(), a Ray call, a Telescope payload, or a queued job's serialised constructor is a leaked private key. Pass a ton_wallets.id into jobs and re-derive inside handle(). Add seed_ciphertext to the model's $hidden, plus any secretKey/seed accessor.
Pin the version and plan for a fork. Zero stars, zero dependents, single maintainer, README-level maturity. That's fine for a well-scoped, well-tested crypto primitive — the algorithm is fixed and the surface is four methods — but treat it as vendorable code: "amashukov/ton-crypto-php": "1.0.*" in composer.json, an interface in front of it, and a mental note that copying two files into app/Support/Ton is a legitimate escape hatch. It's MIT; the license is the insurance policy.
Performance and side channels. 100 000 iterations is deliberate and you shouldn't tune it — the constants are public for assertions, not configuration. Key comparison and signing are handled inside libsodium, so don't hand-roll anything around them.
How can I help you explore Laravel packages today?