Skip to content
Get startedGetting started →

Create passkey accounts

Prerequisites:

  • @category-labs/mera, @scure/bip32, @scure/bip39, and @noble/hashes installed.
  • A PRF-capable authenticator (authenticator support).
The recipe's two flows. On the first visit, createPasskeyWithPrfOutput creates the passkey and the app stores the credential metadata, credentialId and transports, which holds no key material. On a returning visit, getPasskeyPrfOutput signs in using the stored metadata. The PRF output becomes the seed through BIP-39, held in memory for the session, and HD derivation by index produces the EVM and Solana signing sessions, ended by end(). first visit Create the passkey createPasskeyWithPrfOutput Credential metadata returning visit Sign in getPasskeyPrfOutput PRF output BIP-39 Seed derivation by index Signing sessions end()
import { createPasskeyWithPrfOutput } from "@category-labs/mera";
const rpId = location.hostname;
const created = await createPasskeyWithPrfOutput({
rp: { id: rpId, name: "Example" },
user: { name: "account@example.com", displayName: "Example account" },
});

Every call creates a new passkey.

The app can store the credential metadata and pass it back on the next sign-in, so the browser reuses the same passkey instead of offering a choice.

localStorage.setItem(
"app.derivedCredential",
JSON.stringify({
credentialId: created.credentialId,
transports: created.transports,
}),
);

transports is optional and only a hint: the browser uses it to reach the authenticator directly (a platform prompt for a platform passkey, a QR flow for a phone) instead of offering every option.

import { getPasskeyPrfOutput } from "@category-labs/mera";
const stored = localStorage.getItem("app.derivedCredential");
const known = stored ? JSON.parse(stored) : undefined;
const { prfOutput, credentialId } = await getPasskeyPrfOutput({
rpId,
credential: known,
});
// With no stored record, the browser may have used any discoverable passkey.
const record =
known?.credentialId === credentialId ? known : { credentialId };
localStorage.setItem("app.derivedCredential", JSON.stringify(record));

Turn the PRF output into a BIP-39 seed.

import { entropyToMnemonic, mnemonicToSeedSync } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
const seed = mnemonicToSeedSync(entropyToMnemonic(prfOutput, wordlist));

EVM accounts follow BIP-32 over the BIP-44 Ethereum path:

import {
createSecp256k1SigningSession,
getEvmAddress,
} from "@category-labs/mera";
import { HDKey } from "@scure/bip32";
function deriveEvmAccount(seed: Uint8Array, index: number) {
const ethereumAccountPath = `m/44'/60'/0'/0/${index}`;
const node = HDKey.fromMasterSeed(seed).derive(ethereumAccountPath);
if (node.privateKey === null) throw new Error("derivation produced no key");
const session = createSecp256k1SigningSession({
privateKey: node.privateKey,
});
return { session, address: getEvmAddress(session.publicKey) };
}

Solana uses SLIP-0010, the Ed25519 counterpart of BIP-32, and its derivation is a short chain of HMAC (keyed hash) calls:

import {
createEd25519SigningSession,
getSolanaAddress,
} from "@category-labs/mera";
import { hmac } from "@noble/hashes/hmac.js";
import { sha512 } from "@noble/hashes/sha2.js";
import { utf8ToBytes } from "@noble/hashes/utils.js";
function deriveSolanaSeed(seed: Uint8Array, index: number): Uint8Array {
// The SLIP-0010 path m/44'/501'/{index}'/0'; every step is hardened.
const solanaAccountPath = [44, 501, index, 0];
let i = hmac(sha512, utf8ToBytes("ed25519 seed"), seed);
for (const step of solanaAccountPath) {
const data = new Uint8Array(1 + 32 + 4);
data.set(i.slice(0, 32), 1);
new DataView(data.buffer).setUint32(33, (step + 0x80000000) >>> 0, false);
i = hmac(sha512, i.slice(32), data);
}
return i.slice(0, 32);
}
function deriveSolanaAccount(seed: Uint8Array, index: number) {
const session = createEd25519SigningSession({
privateKey: deriveSolanaSeed(seed, index),
});
return { session, address: getSolanaAddress(session.publicKey) };
}

Build the account list from these helpers:

const accounts = [deriveEvmAccount(seed, 0), deriveSolanaAccount(seed, 0)];
for (const account of accounts) {
account.session.end();
}

Ending a session zeroes the private keys it owns.