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

Link a device and set up recovery

Link a second passkey to your account's keystore, require two devices for high-value accounts, name recovery signers, and recover an account after a lost device behind a wait any device can cancel.

Intermediate30 min

A device-bound passkey cannot be copied off its hardware. That is what makes it a sound root for a wallet, and it is also why a wallet on one device cannot survive losing that device. Recovery on Tenzro Network therefore comes from more than one independent root, never from weakening any single one:

  • More devices. A passkey on your phone, another on your laptop, a third on a security key. Each is device-bound; together they survive the loss of any one.
  • Recovery signers. Keys you name, such as a recovery key or an email verifier, that together can add a new passkey when you have none left.
  • A wait any device can cancel. A recovery does not take effect immediately. While it waits, any passkey still on the account can cancel it.

All of this lives in the account's keystore, which the network holds in consensus state. Every change is a KeystoreUpdate transaction approved by the account's passkeys. In this tutorial you link a second device to the wallet from Build a passkey wallet, require two devices, name recovery signers and walk through a recovery.

Prerequisites

  • A passkey wallet with its account address and human DID (from the previous tutorial or the console wallet).
  • A second device with a passkey-capable authenticator, such as a phone.
  • tenzro-sdk and the tenzro CLI (see CLI reference).
bash
export ACCOUNT=0x...                 # your account
export DID=did:tenzro:human:...      # your human DID
export RPC=https://rpc.tenzro.com

In the TypeScript steps, signer is the passkey you already have, newPasskey the passkey you create on the new device, and newCredential its record (relying party, credential id, public key, AAGUID, backup flags and a label). Build a passkey wallet shows how a passkey becomes a signer.

1. Check whether the wallet is ready

A wallet should not sit behind a single device. Ask the node:

bash
tenzro device wallet-readiness $DID --rpc $RPC

Use ready to decide what your app offers. If it is false, prompt the user to link a device before offering to receive funds.

Read the keystore itself:

bash
tenzro passkey keystore $ACCOUNT

Until its first change, an account's keystore is its first passkey alone. independent_roots counts the roots of control (passkeys held by one sync provider count as one), and may_spend says whether the account can send funds: spending needs two independent roots.

Linking a passkey needs the approval of a passkey already on the account, and the new passkey's own proof that it holds its key. Knowing the account address proves nothing; it is a public identifier.

ts
import { KeystoreClient, RpcClient } from "tenzro-sdk";

const keystore = new KeystoreClient(new RpcClient("https://rpc.tenzro.com"));

let link = await keystore.prepare(ACCOUNT, { add_credential: { credential: newCredential } });
link = await keystore.approve(link, signer); // a passkey already on the account
link = await keystore.prove(link, newPasskey); // the new passkey proves possession
await keystore.send(signer, link, ACCOUNT);

prepare names the keystore's current commitment, so the approvals apply to this change only. The account's first change also carries its anchor, the first passkey, which tenzro_enrollPasskey returned; pass it as the third argument of prepare until the keystore is on chain.

A passkey linked while the account has fewer than two independent roots signs at once, but counts as a root only after 48 hours. During that time any other passkey can unlink it.

3. Review what the keystore holds

bash
tenzro passkey keystore $ACCOUNT

Each credential shows its relying party (the domain of the wallet that registered it), its backup flags and when it joined. A passkey from any wallet provider can join an account this way; its assertions are checked against its own relying party.

4. Require two devices for every change (optional)

ts
let policy = await keystore.prepare(ACCOUNT, { set_policy: { policy: "two_credentials" } });
policy = await keystore.approve(policy, signer);
policy = await keystore.approve(policy, newPasskey);
await keystore.send(signer, policy, ACCOUNT);

With two_credentials, every keystore change needs two passkeys, and no single passkey acts alone.

5. Name recovery signers

Recovery signers approve adding a new passkey when you have lost every device. Each has a role: recovery_key, email_verifier or device. Name them and the threshold in one change:

ts
let recovery = await keystore.prepare(ACCOUNT, { set_recovery: { signers: recoverySigners, threshold: 2 } });
recovery = await keystore.approve(recovery, signer);
recovery = await keystore.approve(recovery, newPasskey);
await keystore.send(signer, recovery, ACCOUNT);

6. Recover after losing a device

On a new device, create a passkey. The recovery signers approve a start_recovery change naming it, and the new passkey proves possession:

ts
let start = await keystore.prepare(ACCOUNT, { start_recovery: { credential: newCredential } });
start = await keystore.approveRecovery(start, recoveryKey);
start = await keystore.approveRecovery(start, emailVerifier);
start = await keystore.prove(start, newPasskey);
await keystore.send(newPasskey, start);

The recovery waits 1 hour when a recovery key and an email verifier approved it, 48 hours for a recovery key alone, and 7 days otherwise. tenzro passkey keystore $ACCOUNT shows it under pending_recovery with its ready_at_ms. Any passkey still on the account can cancel it during the wait:

ts
let cancel = await keystore.prepare(ACCOUNT, "cancel_recovery");
cancel = await keystore.approve(cancel, signer);
await keystore.send(signer, cancel, ACCOUNT);

Once the wait has passed, anyone can finish it:

ts
const finish = await keystore.prepare(ACCOUNT, "finish_recovery");
await keystore.send(newPasskey, finish);

Recovery adds a passkey and removes nothing. Your DID, address and balance are unchanged.

7. Remove a lost device

ts
let unlink = await keystore.prepare(ACCOUNT, { remove_credential: { credential_id: lostCredentialId } });
unlink = await keystore.approve(unlink, newPasskey);
await keystore.send(newPasskey, unlink, ACCOUNT);

Unlinking is approved by a passkey that remains on the account, and the last passkey always stays, so nobody who merely knows your address can lock you out.

Next steps