Cardano key derivation and signing for JavaScript.
m/1852'/1815'/0'/0/0The curve math and hashing come from the audited @noble/curves and @noble/hashes. Those are the only dependencies.
v2 is not a drop-in upgrade. It is ESM-only and needs Node 22.12 or later, and keys, signatures and hashes are Uint8Array instead of Buffer.
Moving to v2 is the recommended path: it is faster than v1, carries two dependencies instead of six and no Node.js builtins, and it will get new features and fixes from here on.
| v1 | v2 |
|---|---|
Buffer in and out |
Uint8Array out; Buffer still accepted as input |
bytes.toString("hex") |
Buffer.from(bytes).toString("hex") (on a Uint8Array, toString("hex") returns comma-separated numbers) |
publicKey.pubKey |
publicKey.toBytes() |
deep imports from @stricahq/bip32ed25519/dist/… |
the package root only |
| Node.js polyfills for browser bundles | no Node.js builtins |
| CommonJS package | ESM only, require(esm) on Node >= 22.12 |
yarn add @stricahq/bip32ed25519
The package is ESM-only and needs Node 22.12 or later. You can still require() it from CommonJS:
const { Bip32PrivateKey } = require("@stricahq/bip32ed25519");
If you compile TypeScript to CommonJS, that needs TypeScript 5.8 or later with "module": "nodenext".
<script src="https://cdn.jsdelivr.net/npm/@stricahq/bip32ed25519/dist/index.min.js"></script>
<script>
const { Bip32PrivateKey } = bip32ed25519;
</script>
The bundle puts everything on a bip32ed25519 global. If you use a bundler instead, you won't need polyfills or aliases, because the package doesn't use any Node.js builtins.
Start with fromEntropy. It takes the entropy of a BIP-39 mnemonic, meaning the bytes the words encode rather than the words themselves, so run the mnemonic through a BIP-39 library first.
import { Bip32PrivateKey } from "@stricahq/bip32ed25519";
const rootKey = await Bip32PrivateKey.fromEntropy(entropy);
// CIP-1852: m / purpose' / coin type' / account' / role / index
const accountKey = rootKey.derivePath("m/1852'/1815'/0'");
const paymentKey = accountKey.derivePath("0/0").toPrivateKey();
const stakeKey = accountKey.derivePath("2/0").toPrivateKey();
const publicKey = paymentKey.toPublicKey();
publicKey.toBytes(); // Uint8Array(32)
publicKey.hash(); // Uint8Array(28), Blake2b-224: the payment credential in an address
In a path, ', h and H all mark a hardened index. You can also derive one step at a time. Both of these lines give the same account key as above:
import { HARDENED_OFFSET } from "@stricahq/bip32ed25519";
rootKey.deriveHardened(1852).deriveHardened(1815).deriveHardened(0);
rootKey.derive(HARDENED_OFFSET + 1852).derive(HARDENED_OFFSET + 1815).derive(HARDENED_OFFSET);
If the wallet has a passphrase (the optional second factor from CIP-3), pass it as a string or as bytes:
const rootKey = await Bip32PrivateKey.fromEntropy(entropy, "passphrase");
Entropy of any length works. For a 24-word Trezor wallet, pass 33 bytes: the 32 bytes of entropy followed by the mnemonic's checksum byte. Trezor's firmware keeps the checksum in, a known quirk that CIP-3 documents.
fromEntropy is async because it runs PBKDF2 with 4096 iterations through WebCrypto, which takes about 2 ms. Where crypto.subtle isn't available, such as on a page served over plain http, it falls back to a JavaScript implementation that takes about 30 ms.
You don't need private keys to generate addresses. An account's extended public key can derive all of its soft (non-hardened) children:
const accountXpub = accountKey.toBip32PublicKey();
const addressKey = accountXpub.derivePath("0/5").toPublicKey(); // the public key of accountKey.derivePath("0/5")
Hardened children can't be derived from a public key, so asking for one throws. To store an xpub, toBytes() gives you its 64 bytes and Bip32PublicKey.fromBytes() loads them back.
sign takes any bytes. To witness a Cardano transaction, you sign the 32-byte hash of its body.
const signature = paymentKey.sign(txHash); // Uint8Array(64)
publicKey.verify(signature, txHash); // true
A signing key from cardano-cli, like a payment.skey file, isn't an extended key. It's a plain 32-byte Ed25519 secret key: the hex in its cborHex after the leading 5820, which is just the CBOR header for 32 bytes. Load it with fromSecretKey and it signs exactly like standard RFC 8032 Ed25519:
import { PrivateKey } from "@stricahq/bip32ed25519";
const key = PrivateKey.fromSecretKey(secretKey);
The PrivateKey constructor takes the 64-byte extended form (kL ‖ kR), the one toPrivateKey() gives you. Pass it 32 bytes and it throws, pointing you to fromSecretKey.
verify acceptsEd25519 verifiers disagree on some edge cases, so verify matches the one Cardano uses. It accepts exactly what libsodium's crypto_sign_verify_detached accepts, and that's what the ledger checks signatures with. If verify accepts a signature, so will the chain, and the other way around. A signature is valid when:
For a signature that isn't 64 bytes, verify just returns false. It never throws because of what a signature contains. None of these rules matter for honestly made signatures, which every Ed25519 verifier accepts. They only settle crafted edge cases.
Everything the package returns is a plain Uint8Array. Wherever it takes bytes, any Uint8Array works, including a Node.js Buffer or one from another realm, like a jsdom test environment.
There are no hex helpers. On Node, use Buffer.from(bytes).toString("hex"). Newer runtimes have bytes.toHex(). Or use whatever hex helper your project already has.
Bad input throws instead of quietly giving you a wrong key. Every function checks its arguments and throws a TypeError naming the one that's wrong. Here's what each one accepts:
| Where | Accepts |
|---|---|
| any byte argument | a Uint8Array, so a string throws, like a mnemonic passed as entropy |
fromEntropy |
non-empty entropy, and a passphrase as a string or bytes |
Bip32PrivateKey |
96 bytes (kL ‖ kR ‖ chain code), where kL has its 3 lowest bits and its highest bit clear and its second-highest bit set |
Bip32PublicKey |
64 bytes (public key ‖ chain code), where the public key is a point on the curve |
PrivateKey |
64 bytes (kL ‖ kR), with the same rule for kL |
PrivateKey.fromSecretKey |
32 bytes |
PublicKey |
32 bytes |
derive |
an integer from 0 to 2^32 − 1, where 2^31 and up are hardened |
deriveHardened |
an integer from 0 to 2^31 − 1 |
derivePath |
an optional m, then decimal indices separated by /. An index marked with ', h or H is hardened and must be below 2^31; the others must be below 2^32 |
Bip32PublicKey.derive, derivePath |
soft indices only. A hardened one throws a plain Error |
Each class keeps its own private copy of the bytes you pass in, and toBytes() hands back a fresh copy every time, so changing an array afterwards never changes a key. The package logs nothing, and no error message ever includes key bytes. It doesn't try to wipe keys from memory, though, since JavaScript has no way to guarantee that.
Measured on an Apple M1 Pro with Node 24.13.0.
| Operation | ops/s |
|---|---|
deriveHardened |
50,576 |
derive, soft, children of one key |
51,590 |
derivePath("m/1852'/1815'/0'/0/i") from the root key |
1,955 |
Bip32PublicKey.derive |
6,521 |
toPublicKey of a new PrivateKey |
4,959 |
sign, 32 bytes |
4,652 |
sign, 1 KB |
4,175 |
verify, 32 bytes |
1,061 |
fromEntropy takes 1.9 ms. A key remembers its public key after computing it, so deriving many soft children of one key (which is what a wallet does for its addresses) only computes it once. Computing a public key and signing each take one constant-time scalar multiplication. That's done by noble, which also blinds the secret scalar.
The browser bundle is 50 KB minified, 19 KB gzipped.
yarn test
The suite checks:
verify against libsodium's verdicts, on the ed25519-speccheck vectors and a fuzzRun yarn build first and it also tests the build: it loads the package through Node's own ESM loader and through require(), and runs the browser bundle with no Node.js globals.
The full API reference is at docs.strica.io/lib/bip32ed25519.
Copyright 2021 Strica
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.