Skip to content
Tenzro
← All tutorials
Tutorial · Keys and identity

Build a passkey wallet

Create a Network 1 wallet in the browser from a WebAuthn passkey: identity derived from the passkey, every signature hybrid P-256 and ML-DSA-65, nothing to write down.

Advanced40 min

On Tenzro Network 1 a person's wallet is a passkey. The passkey lives in the platform authenticator (a phone, a laptop's secure hardware or a security key), the person unlocks it with a fingerprint, face or PIN, and the network derives their identity from it. There is no recovery phrase and no key file.

Every passkey operation is hybrid. The authenticator produces a P-256 WebAuthn signature and the wallet adds an ML-DSA-65 signature over the same operation hash, so a signature stays valid against a future quantum adversary. In this tutorial the ML-DSA-65 key is derived from the passkey itself through the WebAuthn PRF extension: it is computed on demand inside the ceremony, used in memory and wiped. The passkey is the only root.

You will build a small browser app that:

  1. Creates a passkey with user verification required.
  2. Derives the ML-DSA-65 key from the passkey.
  3. Enrolls a smart account and receives a human DID derived from the passkey.
  4. Signs an operation with both legs and has the network verify it.

Prerequisites

  • Node.js 20 or later and a bundler such as Vite.
  • A browser and authenticator that support passkeys with the PRF extension (current Chrome, Edge and Safari with a platform authenticator or a FIDO2 security key).
  • An origin the node accepts as its WebAuthn relying party. The node checks that each assertion's origin falls under the relying-party ID it is configured with. For development, run your own node with TENZRO_WEBAUTHN_RP_ID=localhost and serve the app from http://localhost; in production, point your node at your own registrable domain.
  • Familiarity with ERC-4337 smart accounts helps. See Wallet and Smart-account policies.

1. Set up the project

bash
npm create vite@latest passkey-wallet -- --template vanilla-ts
cd passkey-wallet
npm install tenzro-sdk @noble/post-quantum @noble/hashes

Create src/tenzro.ts with the client. endpoint is the JSON-RPC endpoint and apiEndpoint is the Web API:

ts
import { TenzroClient } from "tenzro-sdk";

export const RP_ID = "localhost"; // your relying-party ID

export const client = new TenzroClient({
  endpoint: "http://localhost:8545",   // your node, or https://rpc.tenzro.xyz
  apiEndpoint: "http://localhost:8080", // your node, or https://api.tenzro.xyz
});

export const hex = (b: Uint8Array) =>
  Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");

export const fromHex = (h: string) =>
  Uint8Array.from(h.replace(/^0x/, "").match(/../g)!, (x) => parseInt(x, 16));

2. Create the passkey

Request a discoverable, user-verified credential on a platform authenticator and ask for the PRF extension. userVerification: "required" is mandatory: a signature without a biometric or PIN check is refused.

ts
import { RP_ID } from "./tenzro";

export async function createPasskey(name: string) {
  const cred = (await navigator.credentials.create({
    publicKey: {
      rp: { id: RP_ID, name: "My Tenzro wallet" },
      user: {
        id: crypto.getRandomValues(new Uint8Array(16)),
        name,
        displayName: name,
      },
      challenge: crypto.getRandomValues(new Uint8Array(32)),
      pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 (P-256)
      authenticatorSelection: {
        authenticatorAttachment: "platform",
        residentKey: "required",
        userVerification: "required",
      },
      attestation: "direct",
      extensions: { prf: {} },
    },
  })) as PublicKeyCredential;

  const res = cred.response as AuthenticatorAttestationResponse;
  if (!cred.getClientExtensionResults().prf?.enabled) {
    throw new Error("This authenticator does not support the PRF extension");
  }

  // SPKI for P-256 ends with the 65-byte SEC1 point 0x04 || x || y.
  const spki = new Uint8Array(res.getPublicKey()!);
  const publicKey = spki.slice(spki.length - 65);

  // Backup-eligible (BE) flag, bit 3 of the authenticator data flags byte.
  const authData = new Uint8Array(res.getAuthenticatorData());
  const synced = (authData[32] & 0x08) !== 0;

  return { credentialId: new Uint8Array(cred.rawId), publicKey, synced };
}

The synced flag matters. A device-bound passkey cannot be copied off its hardware, so it can stand as a root on its own. A synced passkey (one that backs up to a cloud account) is accepted at a lower trust tier and is never allowed to be the only root: the wallet must add a second device-bound passkey or guardians before it holds meaningful value. See Link a device and set up recovery.

3. Derive the post-quantum key from the passkey

WebCrypto has no ML-DSA, so the wallet derives the ML-DSA-65 key from the passkey's PRF output. The PRF result is a secret the authenticator computes from a salt you choose; the same credential and salt always return the same value, and only after user verification.

ts
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { RP_ID } from "./tenzro";

const PRF_SALT = sha256(new TextEncoder().encode("my-wallet/ml-dsa-65/v1"));

function mlDsaFromPrf(prf: ArrayBuffer) {
  const seed = hkdf(sha256, new Uint8Array(prf), undefined, "ml-dsa-65 seed", 32);
  const keys = ml_dsa65.keygen(seed);
  seed.fill(0);
  return keys;
}

// One ceremony: a WebAuthn assertion over `challenge` plus the PRF output.
export async function assertWithPrf(credentialId: Uint8Array, challenge: Uint8Array) {
  const cred = (await navigator.credentials.get({
    publicKey: {
      rpId: RP_ID,
      challenge,
      allowCredentials: [{ type: "public-key", id: credentialId }],
      userVerification: "required",
      extensions: { prf: { eval: { first: PRF_SALT } } },
    },
  })) as PublicKeyCredential;
  const prf = cred.getClientExtensionResults().prf?.results?.first;
  if (!prf) throw new Error("PRF output missing");
  return { response: cred.response as AuthenticatorAssertionResponse, prf: prf as ArrayBuffer };
}

export async function mlDsaPublicKey(credentialId: Uint8Array) {
  const { prf } = await assertWithPrf(credentialId, crypto.getRandomValues(new Uint8Array(32)));
  const { publicKey, secretKey } = mlDsaFromPrf(prf);
  secretKey.fill(0);
  return publicKey; // 1952 bytes
}

export { mlDsaFromPrf };

Nothing here is stored. Each time the wallet needs the post-quantum leg it asks the authenticator again, derives the key, signs and zeroes it.

4. Enroll the smart account

Send the P-256 public key, the credential ID and the ML-DSA-65 verifying key to the node. Enrollment refuses a passkey without its post-quantum leg.

ts
import { client, hex } from "./tenzro";
import { createPasskey } from "./create";
import { mlDsaPublicKey } from "./pq";

const pk = await createPasskey("alice");
const pq = await mlDsaPublicKey(pk.credentialId);

const account = await client.passkeyRpc.enroll({
  display_name: "alice",
  passkey_public_key_hex: hex(pk.publicKey),
  credential_id_hex: hex(pk.credentialId),
  ml_dsa_public_key_hex: hex(pq),
});

console.log(account);

Expected output:

json
{
  "did": "did:tenzro:human:...",
  "smart_account_address": "0x...",
  "credential_id_hex": "...",
  "webauthn_validator_address": "0x...",
  "installed_validators": ["0x..."]
}

The did is your human identity, derived from the passkey. The smart account is an ERC-7579 account whose WebAuthn validator module checks both signature legs on every operation. Keep the credential ID and account address in your app's state; neither is secret.

5. Sign an operation

An operation hash is the 32-byte digest of whatever you are authorising, for example an ERC-4337 UserOperation. The WebAuthn challenge is the hash itself, so one touch produces both legs: the P-256 assertion and, through PRF, the ML-DSA-65 signature over the same bytes.

ts
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { assertWithPrf, mlDsaFromPrf } from "./pq";
import { client, hex } from "./tenzro";

// WebAuthn returns an ASN.1 DER ECDSA signature; the network takes raw r || s.
function derToRaw(der: Uint8Array): Uint8Array {
  let i = 2;
  const take = () => {
    const len = der[i + 1];
    let v = der.slice(i + 2, i + 2 + len);
    i += 2 + len;
    while (v.length > 32 && v[0] === 0) v = v.slice(1);
    const out = new Uint8Array(32);
    out.set(v, 32 - v.length);
    return out;
  };
  const r = take();
  const s = take();
  const raw = new Uint8Array(64);
  raw.set(r, 0);
  raw.set(s, 32);
  return raw;
}

// Both legs over one digest, from one touch.
export async function hybridAssert(credentialId: Uint8Array, digest: Uint8Array) {
  const { response, prf } = await assertWithPrf(credentialId, digest);
  const { secretKey } = mlDsaFromPrf(prf);
  const mlDsaSig = ml_dsa65.sign(digest, secretKey);
  secretKey.fill(0);
  return {
    assertion: {
      authenticator_data: Array.from(new Uint8Array(response.authenticatorData)),
      client_data_json: Array.from(new Uint8Array(response.clientDataJSON)),
      signature: Array.from(derToRaw(new Uint8Array(response.signature))),
      user_handle: null,
    },
    mlDsaSignatureHex: hex(mlDsaSig),
  };
}

export async function signOp(
  account: string,
  credentialId: Uint8Array,
  opHash: Uint8Array,
) {
  const { assertion, mlDsaSignatureHex } = await hybridAssert(credentialId, opHash);
  return client.passkeyRpc.sign({
    account_address: account,
    op_hash_hex: hex(opHash),
    credential_id_hex: hex(credentialId),
    assertion,
    ml_dsa_signature_hex: mlDsaSignatureHex,
  });
}

Try it with a test digest:

ts
const opHash = crypto.getRandomValues(new Uint8Array(32));
console.log(await signOp(account.smart_account_address, pk.credentialId, opHash));

Expected output:

json
{ "verified": true, "validator": "0x...", "op_hash_hex": "..." }

If either leg is missing or does not verify, the node rejects the signature. The same bytes you just built go into userOp.signature when you submit a UserOperation with eth_sendUserOperation.

6. Fund the wallet

Open the console faucet, paste your smart account address and claim TNZO to try things on Network 1. Check the balance from your app:

ts
const wei = await client.getBalance(account.smart_account_address);
console.log(`${wei} wei`);

TNZO has 18 decimals. One native balance backs the EVM, SVM and DAML views of the account, so the same funds are usable from any runtime.

7. Machines use their own hardware

This tutorial covers people. A machine (a server, an agent host, a GPU box) does not hold a passkey. Its identity comes from its own TPM 2.0 or Secure Enclave, and its machine DID is derived from that device key. A machine that has no usable TPM runs under the delegated authority of the person who controls it, which is the passkey case again. See Hardware-rooted keys.

Next steps