lizhichao/one-sm
lizhichao/one-sm is a lightweight PHP library implementing China's SM2, SM3, and SM4 cryptographic algorithms. It offers SM2 key generation, signing, verification, encryption and decryption, SM3 hashing, and SM4 symmetric encryption through a simple API for secure PHP applications.
lizhichao/one-sm is a pure-PHP implementation of the Chinese national crypto standards — SM3 (hash, analogous to SHA-256), SM4 (128-bit block cipher, analogous to AES), and SM2 (elliptic-curve signatures/encryption). It exists because PHP's openssl and hash extensions do not reliably expose SM3/SM4, and because you often need it for 国密合规 (Chinese regulatory compliance) or to interoperate with a partner that has standardized on SM algorithms.
Install and verify in under five minutes:
composer require lizhichao/one-sm
There is no service provider, no facade, no config file to publish. Everything lives in src/ under the OneSm\ namespace and is used directly:
use OneSm\Sm3;
$sm3 = new Sm3();
echo $sm3->sign('abc');
// 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0
That hex string is your first smoke test — it is the published SM3("abc") test vector. If it matches, the package is wired correctly and you can move on. Run it once in php artisan tinker before writing any Laravel code around it.
Where to look first, in this order:
src/Sm3.php — single class, ~200 lines, easiest to read. Confirms the encoding conventions (hex vs. raw) used everywhere else.src/Sm4.php — check the constructor signature (key length), whether IV/mode are supported, and what padding is applied.src/Sm2.php — check the signature output format (DER vs. raw r||s) before you promise interop with a partner.First real use case for a Laravel app is almost always "replace MD5/SHA-1 in an API signing scheme" — e.g. a partner or regulator requires sign = SM3(sorted_params + secret) on outbound webhooks and inbound callbacks. That's a two-line change inside an existing Signer class and is the lowest-risk place to start.
new Sm3() across the appThe classes are stateless and cheap to instantiate, but every call site re-decides encoding, key handling, and comparison logic. Bind a single wrapper in the container and make it the only place that knows about OneSm\:
// app/Services/Gm/GmSigner.php
namespace App\Services\Gm;
use OneSm\Sm3;
class GmSigner
{
public function __construct(private string $secret) {}
/** Canonical: ksort + http_build_query, then HMAC-SM3, hex out. */
public function signRequest(array $params): string
{
ksort($params);
unset($params['sign']);
$payload = urldecode(http_build_query($params));
$sm3 = new Sm3();
// If your installed version exposes hmac(), prefer it:
// return $sm3->hmac($payload, $this->secret);
return $sm3->sign($payload . $this->secret);
}
public function verify(array $params, string $expected): bool
{
return hash_equals($this->signRequest($params), strtolower($expected));
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(GmSigner::class, fn () =>
new GmSigner(config('services.gm.secret'))
);
Config in config/services.php (or a dedicated config/gm.php you create yourself), key from .env. No config:cache surprises because there's nothing to publish.
The pattern that scales best is a middleware for inbound, a Guzzle middleware for outbound, both delegating to GmSigner:
// app/Http/Middleware/VerifyGmSignature.php
public function handle($request, Closure $next)
{
$params = $request->all();
$sign = $request->header('X-Gm-Sign') ?? $params['sign'] ?? '';
abort_unless(app(GmSigner::class)->verify($params, $sign), 401, 'bad signature');
return $next($request);
}
Register it on the specific webhook route group only — never globally.
For "encrypt this column at rest" requirements, an Eloquent cast keeps the cipher out of your domain code:
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use OneSm\Sm4;
class Sm4Encrypted implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return $value === null ? null : (new Sm4($this->key()))->decrypt($value);
}
public function set($model, $key, $value, $attributes)
{
return $value === null ? null : (new Sm4($this->key()))->encrypt($value);
}
private function key(): string
{
// 16 raw bytes — base64-decode the env value, do NOT use the string as-is
return base64_decode(config('gm.sm4_key'));
}
}
Store the ciphertext in a text column (hex doubles the length; base64 is ~1.33×) and prefix the stored value with a key version (v1:...) so rotation is possible later.
use OneSm\Sm2;
$sm2 = new Sm2();
[$pri, $pub] = $sm2->generateKey();
$signature = $sm2->sign($canonicalPayload, $pri);
$ok = $sm2->verify($canonicalPayload, $signature, $pub);
Two operational patterns worth adopting immediately:
encrypted cast or, better, in a secrets manager. Never in git, never in a seeder.Both SM3 (pure PHP, byte-at-a-time) and especially SM2 (elliptic-curve math in userland) are orders of magnitude slower than their OpenSSL equivalents. Anything that signs or encrypts per-row should go through a queued job with a batch, not a synchronous loop inside a request. If you must do it inline, benchmark first — see the gotchas below.
Add a tests/Unit/GmTest.php with the standard vectors. This is the cheapest insurance against a dependency upgrade silently changing behavior:
public function test_sm3_vectors(): void
{
$sm3 = new \OneSm\Sm3();
$this->assertSame(
'66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0',
$sm3->sign('abc')
);
$this->assertSame(
'debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732',
$sm3->sign(str_repeat('abcd', 16))
);
}
Encoding is the #1 source of bugs. sign() returns a lowercase hex string, while encrypt()/decrypt() on SM4 also traffic in hex. That means: hex-encode before storing, hex-decode before sending to a partner expecting raw bytes, and base64 if the transport is JSON/HTTP-header. Pick one convention per boundary and write it down. A signature mismatch that "works in Postman but not in production" is almost always a hex/base64/raw confusion, not a crypto problem.
Compare signatures with hash_equals, and normalize case. The package emits lowercase; many Java/BouncyCastle partners emit uppercase. strtolower() both sides, then hash_equals().
SM4 key and IV must be exactly 16 bytes of raw data. A 16-character ASCII string works by accident; a 32-character hex string silently gets truncated. Always base64_decode or hex2bin your env value and assert strlen($key) === 16 at boot. The same applies to the IV — and never reuse an IV in CBC mode; generate a random one per record and store it alongside the ciphertext.
Check the padding and mode before you commit to a stored format. The default in this package is the simple case (single 16-byte key, PKCS#7 padding), and CBC/IV support varies by version. If you need CBC, confirm against src/Sm4.php on the version you actually installed — and if your partner also uses CBC, get their IV handling and padding in writing. Retrofitting a mode change onto already-encrypted columns is a painful migration.
SM2 signature format is an interop landmine. Java/BouncyCastle, OpenSSL, and various SDKs disagree on DER-encoded ASN.1 vs. raw 64-byte r||s, and on whether the message is hashed with SM3 internally or passed in pre-hashed. Confirm the exact byte format with the counterparty before building, and add a test that verifies a signature produced by their SDK, not just by this package. Also watch leading-zero handling — a coordinate that starts with 0x00 is a classic length-mismatch failure.
Don't use SM3 as a password hash. It's a fast hash; it is not a KDF. Keep password_hash()/bcrypt/Argon2 for user credentials. SM3 belongs in signing, integrity checks, and digest construction.
Performance is a real constraint. Rough order of magnitude on a modern CPU: SM3 is several times slower than hash('sha256'), SM4 is much slower than AES-NI-accelerated openssl_encrypt, and an SM2 signature is a millisecond-scale operation. Measure with a quick php -r loop before putting any of this in a hot path; if it's too slow, batch it, cache the digest, or move to a native library for that specific workload.
Reading big files into sign() is a memory trap. There's no streaming/chunked API in the simple interface — for large uploads, either hash incrementally yourself or write the temp file and process in a queued job with a raised memory_limit.
It is unaudited, pure-PHP crypto with no constant-time guarantees. That's fine for the common case (a compliance checkbox, partner API signing, integrity of non-adversarial data). It is not what you want protecting high-value secrets against a determined local attacker. For that, use a native extension, OpenSSL 3's SM providers, or GmSSL, and keep this package for the request-signing tier.
Debugging tip: the fastest way to isolate "is it the package or my code?" is to cross-check with a reference implementation. openssl dgst -sm3 (OpenSSL 1.1.1+) or the gmssl CLI gives you a trusted SM3/SM4 answer for the same input. If your PHP output matches the CLI, the bug is in your canonicalization or encoding, not the library.
Pin the version. With zero dependents and a low bus factor, the API surface can shift between releases (method names, return encodings). Use a caret constraint, commit composer.lock, and rely on the vector tests above as your upgrade gate.
Extension points are all yours to build. Because there's no framework integration, the natural additions are: a Gm facade over your wrapper, the Sm4Encrypted cast shown above, a gm_signature validation rule, and the artisan key-generation command. Keep them in one app/Services/Gm/ namespace so a future swap to a native backend is a single-directory change.
How can I help you explore Laravel packages today?