Skip to content
Tenzro
โ† All tutorials
Tutorial ยท Keys and identity

Build a passkey wallet

Create a Tenzro Network wallet in the browser from a WebAuthn passkey: identity derived from the passkey, proof of possession at enrollment, nothing to write down.

Advanced40 min

On Tenzro Network 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.

The passkey's P-256 key never leaves the authenticator. Signatures carry a post-quantum ML-DSA-65 leg only when the signing hardware holds an ML-DSA-65 key of its own (a TPM or Secure Enclave key enrolled for the account); a browser passkey signs with P-256 alone, and the node refuses an ML-DSA-65 leg for a key that has none enrolled. Nothing is derived in the page and nothing is stored.

You will build a small browser app that:

  1. Creates a passkey with user verification required.
  2. Proves possession of it and enrolls a smart account, receiving a human DID derived from the passkey.
  3. Signs an operation 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 (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 passkey accounts helps. See Wallet and Device linking and recovery.

1. Set up the project

bash
npm create vite@latest passkey-wallet -- --template vanilla-ts
cd passkey-wallet
npm install tenzro-sdk

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.com
  apiEndpoint: "http://localhost:8080", // your node, or https://api.tenzro.com
});

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));

export const fromBase64Url = (s: string) =>
  Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));

2. Create the passkey

Create src/create.ts. Request a discoverable, user-verified credential on a platform authenticator. 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",
    },
  })) as PublicKeyCredential;

  const res = cred.response as AuthenticatorAttestationResponse;

  // 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, authenticatorData: authData, 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. Sign with the passkey

Create src/sign.ts. A WebAuthn assertion is the passkey's signature over a challenge. The network takes the raw r || s signature with the authenticator data and client data the authenticator signed:

ts
import { RP_ID } 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;
}

// The passkey's assertion over `challenge`, in the shape the network takes.
export async function passkeyAssert(credentialId: Uint8Array, challenge: Uint8Array) {
  const cred = (await navigator.credentials.get({
    publicKey: {
      rpId: RP_ID,
      challenge: challenge.slice(),
      allowCredentials: [{ type: "public-key", id: credentialId.slice() }],
      userVerification: "required",
    },
  })) as PublicKeyCredential;
  const response = cred.response as AuthenticatorAssertionResponse;
  return {
    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,
  };
}

4. Enroll the smart account

Enrollment needs proof that you hold the passkey: an enroll_passkey custody challenge issued for the passkey's public key, with its credential ID as the target, signed by the passkey itself. Without it, anyone who had seen the public key could claim the identity it derives.

ts
import { client, hex, fromBase64Url } from "./tenzro";
import { createPasskey } from "./create";
import { passkeyAssert } from "./sign";

const pk = await createPasskey("alice");
const p256 = hex(pk.publicKey.slice(1)); // x || y

const challenge = await client.passkeyRpc.createCustodyChallenge({
  account_address: p256,
  operation: "enroll_passkey",
  target_hex: hex(pk.credentialId),
});

const account = await client.passkeyRpc.enroll({
  display_name: "alice",
  passkey_public_key_hex: p256,
  credential_id_hex: hex(pk.credentialId),
  registration_authenticator_data_hex: hex(pk.authenticatorData),
  authorization: {
    challenge_id: challenge.challenge_id,
    credential_id_hex: hex(pk.credentialId),
    assertion: await passkeyAssert(pk.credentialId, fromBase64Url(challenge.webauthn_challenge)),
  },
});

console.log(account);

Expected output:

json
{
  "did": "did:tenzro:human:...",
  "smart_account_address": "0x...",
  "credential_id_hex": "...",
  "keystore": { "...": "..." },
  "keystore_commitment": "...",
  "anchor": { "...": "..." }
}

The did is your human identity, derived from the passkey. The account is derived from the passkey, and its keystore (this passkey, until you link more) goes on chain with its first change; anchor describes it until then. 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 a transaction's signing digest. The WebAuthn challenge is the hash itself, and the assertion the passkey returns is the signature that goes with the operation. Try it with a test digest:

ts
const opHash = crypto.getRandomValues(new Uint8Array(32));
const assertion = await passkeyAssert(pk.credentialId, opHash);
console.log(assertion.signature.length); // 64: the P-256 signature, r || s

The node checks the signature against the account's keystore when the operation arrives and rejects it if it does not verify. To send a transaction from the account, the passkey signs the transaction's digest the same way; see Hardware signer SDK.

6. Fund the wallet

Fund the account by transferring TNZO to your smart account address from another wallet or account. 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 and SVM 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

ExploreJoin the network