Embedded Wallet Solution

Recovery

Recovery for an embedded MPC wallet — the four lifecycle events, the sealed recovery bundle, restore() on a fresh device, credential rotation, and how to layer a product-level guardian flow on top of the SDK primitives.

Recovery

A long-lived embedded wallet faces four distinct lifecycle events. The right tool differs for each — there is no single "recovery" method on the SDK.

EventToolPreserves on-chain address?
Process restart, same devicesigner.load({ signerId, blob, passphrase })Yes
Device swap, user still has the export blobsigner.load({ signerId, blob, passphrase })Yes
Device lost, no export blob — rehydrate from the sealed recovery bundlesigner.load({ signerId, recoveryBundle, recovery })Yes
Rotate the recovery credentialloaded.rotateRecoveryCredential({ oldRecovery, newRecovery })Yes — returns a fresh bundle
Rotate the share material (security hygiene)loaded.reshare({ passphrase })Yes

Each path uses the actual SDK primitives — there is no email/SMS/passkey "guardian channel" concept built into the SDK. A product-level guardian flow (notify contacts, collect approvals, gate restore) is something you build at the application layer on top of these primitives; the SDK's job is the cryptographic state transition.

→ The canonical reference for every recovery path is Embedded Agent → Recovery. This page covers when to reach for each one in production.


The mental model

The state of an embedded MPC wallet is captured in two artefacts after create():

  1. The export blobloaded.export(passphrase) returns an encrypted snapshot of the device share + transport keys + cached cohort metadata + presignature pool. signer.load(...) rehydrates the handle from this blob. No network calls — purely local. The export blob is what you persist for "rehydrate on this device after a process restart" and "rehydrate on a new device when the user still has access to the old one."

  2. The recovery bundleloaded.recoveryBundle (a Buffer | null) is a sealed parallel copy of the device share, gated by the credential supplied in the recovery clause at create(). The bundle exists ONLY for 2-of-3 single-agent signers with a non-Noop recovery clause. You persist this out-of-band from the export blob — typically in different storage (export blob in device-local secure storage; recovery bundle in cloud backup, paper backup, or distributed via Social recovery).

Losing both artefacts ends the wallet. The cohort cannot reconstitute a missing device share; recovery means re-producing the device share from durable bytes the application persisted.


Persist the recovery bundle at create time

import { EmbeddedAgent, Curve, RecoveryKind } from '@zafeguard/mpc-sdk';

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  signerId: `user-${userId}`,
  recovery: {
    kind: RecoveryKind.Password,
    password: userPassphrase,
  },
  passphrase: exportPassphrase,
});

// loaded.recoveryBundle exists for the 2-of-3 single-agent + non-Noop shape.
if (loaded.recoveryBundle) {
  await cloudBackup.put(`recovery:${userId}`, loaded.recoveryBundle);
}

// loaded.exportedBlob is already sealed with the create-time `passphrase` —
// persisted in device-local secure storage, separate from the recovery bundle.
await secureStorage.put(`signer:${userId}`, loaded.exportedBlob!);

Two design knobs at this stage:

  • recovery.kind — picks how the bundle is sealed (Password, Social, Custodial, or Noop for no bundle).
  • Where you persist recoveryBundle — cloud backup, paper backup, distributed across contacts, custodial vault. Your product layer makes this decision.

The bundle is opaque AES-256-GCM ciphertext; the agent never sees its contents. A stolen bundle is useless without the recovery credential. A stolen export blob is useless without the export passphrase.

→ See Key Ceremony → Picking a recovery clause for the full discriminated union.


Restart on the same device (load)

The most common operation — the app starts and the user wants to use their wallet:

const blob = await secureStorage.get(`signer:${userId}`);
if (!blob) {
  // First run or wiped storage — either create a new wallet or run recovery.
  return runFirstTimeFlow();
}

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.load({
  signerId: `user-${userId}`,
  blob,
  passphrase: exportPassphrase,
});

// loaded is ready to sign — same publicKey, same threshold, same cohort.
const sigPartials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
  messageHash: sigPartials.messageHash,
  partials: sigPartials.partials,
  derivationPath: sigPartials.derivationPath,
});

load is purely local. The wrong passphrase fails the AEAD tag check immediately with no plaintext leak. signerId must match the id encoded in the blob — a mismatch is rejected up front so a multi-account app can't accidentally rehydrate the wrong signer.

The presignature pool inside the blob is rehydrated too, so a device that pre-minted entries before the restart can resume single-round-trip signing immediately after.


Restore on a fresh device (restore)

When the user lost their device but kept the recovery bundle (cloud backup, password vault, paper recovery, social distribution):

const recoveryBundle = await cloudBackup.get(`recovery:${userId}`);
if (!recoveryBundle) {
  // Truly lost — no path forward without the bundle.
  return showUnrecoverableError();
}

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const restored = await signer.load({
  signerId: `user-${userId}`,
  recoveryBundle,
  recovery: {
    kind: RecoveryKind.Password,
    password: userPassphrase,
  },
});

// restored.publicKey === original loaded.publicKey, byte for byte.
// Addresses are unchanged. Funds are unaffected.
const sigPartials = await restored.sign({ messageHash });
const sig = await restored.finalizeSignature({
  messageHash: sigPartials.messageHash,
  partials: sigPartials.partials,
  derivationPath: sigPartials.derivationPath,
});

Properties:

  • The on-chain address does not change. The recovery bundle re-produces the share that signs against the same DKG-derived public key.
  • The recovered handle plays the recovery party's role. Signing uses recovery_share + agent_share to satisfy the 2-of-3 threshold; the original device's share is replaced operationally.
  • The bundle is single-use by convention. Nothing in the API stops a caller from running restore multiple times against the same bundle, but the recovery credential should be treated as compromised after each use. After a successful restore, immediately:
    • Re-export() the restored handle and persist a fresh export blob.
    • Optionally loaded.rotateRecoveryCredential({ oldRecovery, newRecovery }) to migrate to a fresh credential and produce a fresh bundle.

signerId must match the id encoded in the recovery bundle; mismatch is rejected up front.


Rotate the recovery credential (rotateRecoveryCredential)

loaded.rotateRecoveryCredential({ oldRecovery, newRecovery }) re-seals the recovery bundle under a fresh credential while you STILL HAVE the old one. The caller must supply both oldRecovery (which is verified before any state mutation) and newRecovery — there is no path through this method when the old credential is lost.

Use it for planned credential rotation, not for forgotten-credential recovery:

  • Hygiene rotation on a schedule. The user changes their bundle passphrase periodically while they still remember the current one.
  • Credential-type migration. Move from Password to Social (or vice versa) by supplying a different newRecovery.kind — same call.
  • Custodian HSM pre-rotation. The operations team rotates the custodial HSM keypair ahead of its retirement, using the still-valid current key as oldRecovery.
  • Social distribution list change while still operational. A user reaches the current Social threshold, opens the bundle, then re-seals it across a new contact set.
const freshBundle = await loaded.rotateRecoveryCredential({
  oldRecovery: { kind: RecoveryKind.Password, password: oldPassphrase },
  newRecovery: { kind: RecoveryKind.Password, password: newPassphrase },
});

// Replace the stored bundle with the fresh one. The old bundle no longer opens anything.
await cloudBackup.put(`recovery:${userId}`, freshBundle);

Properties:

  • Old credential is REQUIRED. Passing a wrong oldRecovery rejects with "oldRecovery does not match the credential the current bundle was sealed with" before any state mutation, so a stale prompt cannot quietly re-seal the share under an attacker-supplied value.
  • Local-only. rotateRecoveryCredential does not contact the cohort. The credential change is a re-seal of the same underlying share material under a new credential.
  • You can change RecoveryKind here too. Rotate from Password to Social or vice versa by specifying a different newRecovery.kind — the migration is one call.

When the old credential is genuinely lost

rotateRecoveryCredential() is the wrong tool — it can't help if the user has forgotten their passphrase, the custodial HSM key is gone, or the Social contact pool no longer reaches threshold. Those cases are permanent loss of the recovery bundle, and the wallet's continued operation depends on whether the OTHER artefact (the export blob, persisted by the application on the user's device) is still available:

State after credential lossPath forward
Export blob still on the user's device + its passphrase still knownsigner.load({ signerId, blob, passphrase }) keeps the wallet operational. Then call loaded.reshare({ recovery: newRecovery, passphrase: exportPassphrase }) to mint a fresh bundle under a new credential — this is the safest way to recover from a lost bundle credential while you still have the device-side share.
Export blob gone tooThe wallet is unrecoverable. The cohort cannot reconstitute a missing device share without one of the two persisted artefacts. Product layers should surface this state clearly — "lost wallet" — and re-onboarding into a fresh create() is the only path forward.

Document this in your product's recovery UI so users don't expect rotateRecoveryCredential() to handle the "I forgot my passphrase" case.


Rotate share material (reshare)

Periodic share rotation is a security hygiene measure. The cohort agents may have had their API keys rotated; you may want fresh share material as a routine practice. loaded.reshare() rotates the share material across the cohort while preserving the public key:

const rotated = await loaded.reshare({
  // Required when the source handle has a non-empty recoveryBundle —
  // the credential needed to re-seal the new bundle.
  recovery: { kind: RecoveryKind.Password, password: userPassphrase },
  passphrase: exportPassphrase,    // seals rotated.exportedBlob
});

// rotated.publicKey === loaded.publicKey
// rotated.exportedBlob is the freshly-sealed at-rest blob (sealed
// inline by reshare under `exportPassphrase`).
// rotated.recoveryBundle is a fresh sealed copy of the new share
// (when the source handle had one).
await secureStorage.put(`signer:${userId}`, rotated.exportedBlob!);
if (rotated.recoveryBundle) {
  await cloudBackup.put(`recovery:${userId}`, rotated.recoveryBundle);
}

reshare is same-cohort only — the new cohort is identical in size, threshold, and party indices. To change the cohort shape (add a party, change the threshold, replace an agent) you need a fresh wallet via create and a controlled transfer of funds. There is no in-place topology reshape today.


Building a product-level guardian flow

The SDK's RecoveryKind.Social distributes the recovery bundle across N contacts via Shamir's secret sharing — any K of N can reconstitute. The cryptography is real; the UX of "ask the contacts" is yours to build.

A typical product-level guardian flow on top of RecoveryKind.Social:

At create()
  → Collect K-of-N X25519 public keys from the user's chosen contacts.
  → Pass them as socialRecipientsHex.
  → The SDK splits the share into N fragments, each sealed to one contact's pubkey.
  → Distribute each fragment to the corresponding contact via your channel.

At restore()
  → User initiates recovery on a fresh device — your app collects fragments back from contacts.
  → Each contact decrypts their fragment using their X25519 secret key (your app must give them tooling).
  → Once K fragments are collected, run signer.load({ recoveryBundle, recovery: { kind: RecoveryKind.Social, socialThreshold: K } })
  → The SDK reassembles the bundle and re-produces the share on the new device.

The SDK enforces the threshold: fewer than K fragments cannot reconstitute the bundle, regardless of which contacts cooperate. Time-locks, geo-checks, "this was not me" cancel flows — those are layers your application builds on top of the fragment-collection step, not properties of the SDK.

The existing reference doc Embedded Agent → Recovery → Social has the cryptographic details and the X25519 keygen pattern.


What the SDK does NOT do

To avoid confusion when designing recovery UX:

  • No built-in email/SMS notification channels. The SDK delivers fragments via byte buffers; you choose how to ship them to contacts.
  • No built-in time-lock or cancel-link. Those are product features your application layer adds around restore.
  • No built-in "this device is invalidated" propagation. A successful restore on a new device does not auto-invalidate the old device's share — if both export blobs are intact, both devices can sign. Your application logic decides whether to gate this and how.
  • No automatic guardian quorum. RecoveryKind.Social enforces K-of-N cryptographically; your application layer collects the K fragments by whatever means and presents them to the SDK as a single recoveryBundle Buffer.

These are all reasonable things to build at the product layer — they just don't ship with the SDK as APIs you can call.


Next

On this page