bitpay/key-utils
PHP utilities from BitPay for Bitcoin wallet key management, including BIP32 hierarchical deterministic key derivation and BIP39 mnemonic support, with helpers for generating, deriving, and handling secure keys in any PHP project.
bitpay/key-utils is a small, framework-agnostic PHP cryptography package. It is not a Laravel package: there is no service provider, facade, config file, or migration. You install it, call static methods on BitPay\KeyUtils, and wire it into your app yourself.
composer require bitpay/key-utils
The package depends on PHP's OpenSSL extension (and, depending on version, GMP). Before building anything, confirm your runtime can create the curve BitPay uses:
// tinker or a one-off route
dd(in_array('secp256k1', openssl_get_curve_names(), true));
If that returns false, stop and fix the platform before writing application code. On OpenSSL 3 this usually means the secp256k1 curve is unavailable through the default provider, or the container lacks the right OpenSSL build.
vendor/bitpay/key-utils/src/BitPay/KeyUtils.php — the whole public API lives here.vendor/bitpay/key-utils/tests/ — the most reliable usage examples for the installed version.composer show bitpay/key-utils — confirm the exact version and PHP constraint.Do not assume the API is identical across versions. Read KeyUtils.php in the vendor directory and treat that as the contract.
The typical first task is generating a key pair for a BitPay merchant/store, deriving the public identity (SIN), and signing a request body.
use BitPay\KeyUtils;
$privateKey = KeyUtils::generateKey(); // PEM-encoded EC private key
$publicKey = KeyUtils::derivePublicKey($privateKey); // PEM-encoded public key
$sin = KeyUtils::getSin($publicKey); // merchant identity
$payload = json_encode(['facade' => 'merchant', 'label' => 'My Store']);
$signature = KeyUtils::sign($privateKey, $payload); // base64 ECDSA signature
$valid = KeyUtils::verify($publicKey, $payload, $signature); // bool
Store the private key encrypted, store the public key and SIN alongside it, and never regenerate the pair per request. Key generation is relatively slow and, more importantly, rotating keys breaks every token and pairing associated with the old key.
Minimal config: none from the package. Put your own values in config/bitpay.php and read them through config(), not env(), so php artisan config:cache behaves correctly.
Do not scatter KeyUtils:: calls through controllers and jobs. Create a small service that owns key loading and exposes intention-revealing methods.
namespace App\Services;
use BitPay\KeyUtils;
class BitPayKeyService
{
public function __construct(private readonly string $privateKeyPem)
{
}
public function sin(): string
{
return KeyUtils::getSin(
KeyUtils::derivePublicKey($this->privateKeyPem)
);
}
public function sign(string $payload): string
{
return KeyUtils::sign($this->privateKeyPem, $payload);
}
public function verify(string $payload, string $signature): bool
{
return KeyUtils::verify(
KeyUtils::derivePublicKey($this->privateKeyPem),
$payload,
$signature
);
}
}
Resolve the PEM from a BitPayKey model or secret manager in a service provider:
$this->app->bind(BitPayKeyService::class, function () {
$record = BitPayKey::where('active', true)->firstOrFail();
return new BitPayKeyService(
\Crypt::decryptString($record->private_key_encrypted)
);
});
Cache the derived public key and SIN, not the private key. Deriving the public key on every request is unnecessary work; decrypting and holding the private key longer than needed is a security concern.
A simple model works well:
Schema::create('bitpay_keys', function (Blueprint $table) {
$table->id();
$table->text('private_key_encrypted'); // Crypt::encryptString($pem)
$table->text('public_key'); // safe to store plaintext
$table->string('sin')->unique();
$table->string('label')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
Use Laravel's Crypt for at-rest encryption. The encrypted payload is base64 text, so a text column is appropriate. Keep the public key and SIN in plaintext; they are identities, not secrets.
BitPay's JSON API expects the exact request body signed and sent in the x-signature header, with the SIN in x-identity. The critical detail is that you must sign the exact bytes you transmit. Encode the JSON once, sign that string, and send that same string.
With Laravel's HTTP client:
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$response = Http::withBody($body, 'application/json')
->withHeaders([
'x-identity' => $keys->sin(),
'x-signature' => $keys->sign($body),
'x-accept-version' => '2.0.0',
])
->post('https://bitpay.com/invoices');
If you use Guzzle directly, a middleware keeps signing out of every call site:
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
$stack->push(Middleware::mapRequest(function (RequestInterface $request) use ($keys) {
$body = (string) $request->getBody();
return $request
->withHeader('x-identity', $keys->sin())
->withHeader('x-signature', $keys->sign($body));
}));
Avoid Http::post($url, $array) in signed flows, because you lose control over the exact serialized body. JSON key order, whitespace, and slash escaping all change the bytes and therefore the signature.
Anything claiming to come from BitPay should be verified before you trust it. Verify the raw request body, not the re-encoded array:
$raw = $request->getContent();
$signature = $request->header('x-signature');
abort_unless($keys->verify($raw, $signature), 401);
Use $request->getContent() rather than $request->all() or json_encode($request->all()). Middleware that parses and re-serializes JSON will break signature verification.
The usual lifecycle is: generate a key pair, derive the SIN, create a pairing token with BitPay, then persist the returned token against the key. The key-utils package handles the cryptographic part only; the HTTP calls belong in your client service.
Keep a one-to-one or one-to-many relationship between keys and tokens, and record which key signed which token. When a token is revoked, mark the key inactive rather than deleting it, so audit history remains intact.
Do not generate fresh keys in every test. Generate one fixture pair once, commit it only if it is clearly test-only, and inject it into the service.
public function test_signature_round_trip(): void
{
$private = file_get_contents(base_path('tests/fixtures/bitpay-test-key.pem'));
$service = new BitPayKeyService($private);
$payload = '{"facade":"merchant"}';
$sig = $service->sign($payload);
$this->assertTrue($service->verify($payload, $sig));
$this->assertFalse($service->verify($payload . ' ', $sig));
}
For feature tests, bind a fake implementation of your own interface rather than hitting OpenSSL at all. The important behavior to test is "the request was signed with the right identity" and "invalid webhook signatures are rejected," not the internals of ECDSA.
If you support multiple merchants, resolve the key by store/tenant rather than using one global key. A BitPayKeyRepository that accepts a store ID keeps this clean. For rotation, add a new active key, switch signing to it, keep the old key available for verifying incoming messages until BitPay stops using it, then deactivate it.
secp256k1 is in openssl_get_curve_names(). On OpenSSL 3, missing legacy/provider configuration can cause key generation or derivation to fail with an opaque error. Always check openssl_error_string() when an operation returns false..env do not mix. A PEM contains literal newlines. If you must put it in an environment variable, base64-encode it and decode at runtime, or store a file path. Escaped \n strings are a common source of "invalid key" errors.json_encode($array), then send $request->all() or a client that re-encodes. Capture the body once and reuse it.verify.bool, int (1/0/-1), or false on error. Normalize to a strict boolean at your service boundary so a -1 error never gets treated as success.#[SensitiveParameter] on methods that accept the PEM if you are on PHP 8.2+.config:cache will bite you. Any env() call in package-adjacent code returns null after caching. Put values in config/bitpay.php and read them via config().composer.json, read the installed source, and keep your own BitPayKeyService interface so you can swap to direct OpenSSL calls or a maintained SDK without touching call sites. The MIT license means forking or vendoring is viable if needed.composer show and run your test suite on the PHP version you deploy.getSin() on the key produced by derivePublicKey() rather than computing it yourself.KeyUtils is a static utility, which is hard to mock. Your own interface with sign, verify, and sin methods gives you testability and a migration path. That wrapper is the single most valuable thing you can add around this package.How can I help you explore Laravel packages today?