Hardware signer SDK
Sign with passkeys, TPM 2.0 and Secure Enclave keys from your own application through tenzro-sdk, with hybrid P-256 and ML-DSA-65 signatures.
tenzro-sdk gives your application the same hardware-rooted signing the console uses. People sign with passkeys; machines and agents sign with a TPM 2.0 or Secure Enclave key. Your application never holds a key: it asks the hardware to sign, sends the result to the network and forgets it.
npm install tenzro-sdk # TypeScript
cargo add tenzro-sdk # RustThe model
The SDK separates three things:
| Piece | Job |
|---|---|
Signer | Produces a signature over a 32-byte hash. Implemented by the hardware: a passkey authenticator, a TPM or a Secure Enclave. |
HybridSigner | What transactions are signed with: the P-256 leg (a passkey's WebAuthn assertion, or a TPM or Secure Enclave signature) and, where the hardware holds one, the ML-DSA-65 leg. TypedTxClient and KeystoreClient take one. |
| Account | A passkey account's keystore, held in consensus state, names the passkeys that act for it. See Device linking and recovery. |
Every signature comes from hardware: the classical leg is P-256 from the passkey, TPM or Secure Enclave, and where the hardware also holds an ML-DSA-65 key (the Secure Enclave on OS 26 and later, TPM 2.0 v1.85) the post-quantum leg is added and the account checks both as one composite signature. Nothing is derived in software. See Hardware-rooted keys.
Passkey signers
Create a wallet
The passkey is made in the browser with navigator.credentials.create() under your site's relying party. Its public key derives the account and the identity; enrolling records nothing until the account's first keystore change.
import { TenzroClient } from "tenzro-sdk";
const client = new TenzroClient({ endpoint: "https://rpc.tenzro.com" });
// Proof of possession: the new passkey signs an enroll_passkey challenge
// issued for its own public key, with its credential id as the target.
const challenge = await client.passkeyRpc.createCustodyChallenge({
account_address: passkeyPublicKeyHex,
operation: "enroll_passkey",
target_hex: credentialIdHex,
});
const account = await client.passkeyRpc.enroll({
display_name: "Alice",
passkey_public_key_hex: passkeyPublicKeyHex,
credential_id_hex: credentialIdHex,
registration_authenticator_data_hex: registrationAuthenticatorDataHex,
rp_id: "example.com",
authorization: {
challenge_id: challenge.challenge_id,
credential_id_hex: credentialIdHex,
assertion: await assertionOver(credentialIdHex, challenge.webauthn_challenge),
},
});
console.log(account.did); // did:tenzro:human:...
console.log(account.smart_account_address); // 0x...registrationAuthenticatorDataHex is the authenticatorData of the registration the passkey was created with, and assertionOver is the passkey's WebAuthn assertion over the challenge; Build a passkey wallet shows both.
Send from the account
A passkey account sends native transactions like any other: client.transactions (a TypedTxClient) builds the transaction, the node reports the digest to sign, and the passkey signs base64url(SHA-256(M')) as its WebAuthn challenge. Name the account as the sender; consensus checks the passkey against the account's keystore and pays the fee from the account at the node's gas price.
await client.transactions.send(signer, {
kind: "Transfer",
fields: { amount: 1_000_000_000_000_000 },
to: "0x" + "11".repeat(32),
from: account.smart_account_address,
});signer is a HybridSigner over the passkey: its signComposite returns the WebAuthn assertion as the P-256 leg. Spending needs two independent roots on the account; see Device linking and recovery.
Keystore changes
An account's devices, policy and recovery signers are its keystore, held in consensus state. Linking or unlinking a device, changing the policy, naming recovery signers and running a recovery are each a KeystoreUpdate transaction: prepare the change, collect approvals from the account's passkeys, and send it.
import { KeystoreClient, RpcClient } from "tenzro-sdk";
const keystore = new KeystoreClient(new RpcClient("https://rpc.tenzro.com"));
const view = await keystore.getKeystore(account.smart_account_address);
console.log(view.independent_roots, view.may_spend);
// Unlink a lost device, approved by a passkey still on the account.
let unlink = await keystore.prepare(account.smart_account_address, {
remove_credential: { credential_id: lostCredentialIdHex },
});
unlink = await keystore.approve(unlink, signer);
await keystore.send(signer, unlink, account.smart_account_address);prepare names the keystore's current commitment, so an approval applies to exactly one change. approve adds a passkey's approval, approveRecovery a recovery signer's, and prove the proof of possession of a passkey the change adds. send signs and pays for the transaction at the node's gas price (or the one you pass); sent from the account itself, the sender's passkey also counts as an approval. An account with no balance yet can have another account send the change and pay its fee, such as a wallet provider's sponsor: the passkeys' approvals in the change are its authority, so the sponsor cannot change it. resolveCredential finds the accounts a passkey is linked to. See Device linking and recovery.
TPM 2.0 and Secure Enclave signers
Machines and agents sign with the device's own hardware through the same Signer interface.
import type { SignContext, SignerKind, SignerSignature } from "tenzro-sdk";
interface Signer {
describe(): SignerKind;
sign(hash: Uint8Array, context: SignContext): Promise<SignerSignature>;
}- On Linux and Windows the key lives in the TPM 2.0. On Apple hardware it lives in the Secure Enclave, which signs with P-256.
- The ML-DSA-65 companion key is derived from the hardware root when a signature is needed, used in memory and wiped.
SignContextcarries apromptReasonfor a user-presence prompt where the key requires one, adomainTagthe signer must match before it signs, and adeadlineMs.- Storage backends report their
capabilities(). A hardware-backed key reportshardwareBacked: trueandexportable: false; there is no export path for it.
The same hardware key gives the machine its DID (did:tenzro:machine:...). A machine that acts for a person is registered under that person's human DID with a delegation scope; see Identity and Agents.
Errors
A signer fails with a typed SignerError:
| Kind | Meaning |
|---|---|
user-cancelled | The user dismissed the prompt |
authentication-failed | User verification failed or the key was rejected |
timeout | The deadline passed |
domain-tag-mismatch | The request's domain tag did not match the signer's |
backend-unavailable | No suitable hardware or authenticator is present |
transport | The request could not reach the authenticator or node |