Embedded Agent

Recovery

Embedded Agent state survives process restarts, device swaps, share rotation, and cohort emergencies. Pick the path that matches the failure mode you're handling.

Recovery

A long-lived embedded wallet faces four distinct lifecycle events. The right tool differs for each:

EventToolPreserves on-chain address?
Process restart, same device (app reopen, OS reboot)signer.load({ blob, passphrase }) — uses the on-device exportedBlobYes
New device, original device available (planned migration, device upgrade)Copy the exportedBlob to the new device first, then signer.load({ blob, passphrase })Yes
New device, original device gone (lost phone, factory reset, data wipe)signer.load({ recoveryBundle, recovery }) — uses the off-device recoveryBundleYes
Routine share rotation (security hygiene, API-key change)loaded.reshare({ passphrase })Yes
Cohort party lost or compromisedCold-recovery via ClusterAgent.exportRecoveryBundle + RecoveryBundle.recoverYes — the recovered private key can be re-minted into a new cohort with the same root pubkey
Emergency recovery — extract the raw root private key off-cluster (MPC infrastructure unreachable, migrate off MPC, regulator-mandated extraction)ClusterAgent.exportRecoveryBundle from a quorum of cohort agents → RecoveryBundle.finalizeRecoveryBundle.recover on an air-gapped deviceYes — same root pubkey; the recovered private key signs against every address derived from it

Each path is detailed below.

Two artifacts, two secrets — don't confuse them

create() produces two parallel sealed snapshots of the device share. Same plaintext share inside, two different seals, two different homes, two different restore paths. Mixing them up is the most common cause of unrecoverable wallets.

                     ┌──────── create({ passphrase, recovery }) ────────┐
                     │                                                  │
                     ▼                                                  ▼
          loaded.exportedBlob                              loaded.recoveryBundle
          sealed by `passphrase`                           sealed by `recovery.password`
          (export passphrase)                              (recovery credential)
                     │                                                  │
                     ▼                                                  ▼
          ┌─ On-device secure storage ─┐                ┌─ Off-device backup ──────────┐
          │  iOS Keychain / Android    │                │  Cloud sync, paper backup,   │
          │  Keystore / OS keyring     │                │  Shamir guardians, etc.      │
          └────────────────────────────┘                └──────────────────────────────┘
                     │                                                  │
                     ▼                                                  ▼
          signer.load({ blob, passphrase })           signer.load({ recoveryBundle, recovery })
          Routine path: same device,                  Disaster path: new device after the
          new process.                                original device is gone.

The two secrets are unrelated and must be different strings — using the same value for both collapses two security boundaries into one. The two artifacts must live in different storage — putting both on the same device defeats the off-device recovery model (lose the device → lose the wallet).

Full comparison tables in the SDK reference: Two passphrases + Two artifacts.


1. Restart and device swap (export()load())

Every LoadedKeyShare snapshots into an encrypted blob. create() produces the first snapshot inline and exposes it on loaded.exportedBlob; later in the lifecycle, loaded.export(newPassphrase) produces a fresh snapshot under a different passphrase. The blob carries the device share, the transport keypairs, and the cached cohort metadata — everything needed to reconstitute the handle. Reload it with signer.load({ keyId, blob, passphrase }) in a future process or on a different device.

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

// ─── First run ────────────────────────────────────────────────────
const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});
const loaded = await signer.create({
  keyId: 'user-eth-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',         // seals loaded.exportedBlob
});
// The blob was sealed inside create() and is ready to persist — no
// separate loaded.export(...) call needed for the routine path.
await secureStorage.put('signer-state', loaded.exportedBlob!);

// ─── Later (process restart or fresh device) ─────────────────────
const signer2 = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});
const restored = await signer2.load({
  keyId: 'user-eth-wallet',
  blob: await secureStorage.get('signer-state'),
  passphrase: 'demo-passphrase',         // same passphrase that sealed it
});
// restored.publicKey === loaded.publicKey, byte-for-byte

Call loaded.export(newPassphrase) later only when you want to re-seal under a different passphrase — e.g. rotating the device's at-rest secret after an HSM key rotation, or producing a snapshot under a one-off passphrase to ship the blob to a backup location.

load() is purely local — no network call until you next call sign(). The blob is scrypt-derived key → AES-256-GCM(salt || nonce || ciphertext || tag). Wrong passphrase fails the AEAD tag check immediately with no plaintext leak. The agent never sees the blob.

The blob also carries the presignature pool at export time, so a device that pre-minted N presignatures and exported can keep signing against the pool after restart.


2. Sealed-bundle cold backup (the RecoveryKind plugins)

By default create() produces no extra backup — losing the export() blob plus its passphrase ends the wallet. For belt-and-suspenders, pass a non-Noop recovery clause to create() and the SDK ALSO produces a sealed recoveryBundle. The bundle is a parallel copy of the device share, sealed under whatever credential the recovery clause specifies. Persist it independently from the export() blob (paper backup, second device, encrypted email) and use signer.load({ ... }) to reconstitute on a fresh device.

The three sealed-bundle variants:

// Password — single passphrase the user memorizes / writes down.
const loaded = await signer.create({
  keyId,
  recovery: { kind: RecoveryKind.Password, password: 'twelve word seed phrase here' },
  passphrase: 'demo-passphrase',
});
const bundle = loaded.recoveryBundle!;
await cloudBackup.put(`recovery:${keyId}`, bundle);

// Social — Shamir-split the bundle across N trusted contacts (any K reconstitute).
const loaded = await signer.create({
  keyId,
  recovery: {
    kind: RecoveryKind.Social,
    socialThreshold: 3,
    socialRecipientsHex: [
      spouseX25519PubHex,
      siblingX25519PubHex,
      lawyerX25519PubHex,
      friendX25519PubHex,
    ],
  },
  passphrase: 'demo-passphrase',
});

// Custodial — RSA-OAEP sealed to a custodian's HSM public key.
const loaded = await signer.create({
  keyId,
  recovery: {
    kind: RecoveryKind.Custodial,
    custodialPublicKeyPem: hsmPublicKeyPem,
  },
  passphrase: 'demo-passphrase',
});

Each variant produces a sealed Buffer at loaded.recoveryBundle. The bytes are opaque to JS callers — persist them somewhere durable (cloud sync, printed QR, encrypted backup, custodian's KMS). Restore later via signer.load({ keyId, recoveryBundle, recovery }), supplying the same recovery clause variant.

The sealed bundle is independent of the export() blob. Lose the export blob → restore from the recovery bundle. Lose the recovery bundle → restore from the export blob. Lose both → wallet is gone.

Sealed-bundle backup only works with single-agent cohorts today. Multi-agent topologies don't carry a sealed bundle (the recovery is the live recovery agents themselves); the SDK enforces this with a constructor-time check that rejects a non-Noop recovery clause when agent.length > 1.


3. Share rotation (reshare())

Rotation is a different operation from recovery — it doesn't move data, it ROTATES the cryptographic material across the cohort. Every party (device + agents) ends up with a fresh share that signs against the same public key. Use it on a defined cadence (every 90 days), after a near-miss security incident, or when an agent's API key gets rotated for unrelated reasons.

Operational facts an incident-response runbook should know

  • Reshare is device-driven. loaded.reshare(...) is a method on the device's LoadedKeyShare handle, which means the wallet's blob must be loaded on the device for rotation to proceed. Wallets whose device is offline at rotation time cannot be rotated — the cohort agents wait, but no shares move until the device participates. For incident response where you want to drain a compromised agent's exposure window quickly, this is the rate-limiting step.
  • The compromised agent stays in the cohort during rotation. Reshare runs against the same agents[] list the original EmbeddedAgent was constructed with — including the agent you're rotating away from. The agent participates in the rotation ceremony (it has to, since it holds a current-epoch share) but exits with a fresh share that doesn't match its old one. After the device persists reshared.exportedBlob, the agent's old share is discarded and the new share is the only one it can use. There is no way to reshare a wallet without the compromised agent participating; if the agent is unreachable or you've revoked its API key, the ceremony fails and the wallet stays on the old shares.
  • The presignature pool is invalidated. Reshare wipes the wallet's presignature pool — every pre-minted entry was bound to the old shares' Lagrange coefficients and is cryptographically meaningless after rotation. The first sign() after reshare runs the full presign + sign round-trip (one extra cohort interaction) unless the caller pre-mints a fresh pool.
  • Concurrent signing during reshare is a race. If a sign() is in flight on the OLD handle when reshare lands, the agent rejects the sign with a "session expired" error and the partial signatures collected so far are discarded. There is no graceful resume — the caller retries on the new handle. Best practice: gate reshare on a "no in-flight signatures for this wallet" lock in your orchestrator, or accept that a small number of signs will fail with retryable errors during the rotation window.
  • Re-running reshare on the same wallet is idempotent at the agent. If the ceremony crashes mid-rotation (network blip, process restart), retrying loaded.reshare(...) against the same source blob produces a fresh rotation — the agent advances its session state monotonically. Persist the new reshared.exportedBlob before discarding the old loaded.exportedBlob — if the new blob is lost before persistence, you can still load from the old one and reshare again.

For fleet rotation (tens of thousands of wallets), the SDK ships per-wallet primitives only — orchestration (queue, retries, per-wallet idempotency markers, concurrency caps against the agent) is the integrator's job. A reasonable shape: a job table with one row per wallet keyed by keyId, a reshare_state column (pending/completed/failed/device-offline), workers that load → reshare → persist with a per-wallet lock, and a fleet-wide concurrency cap matched to the agent's rate limits.

const reshared = await loaded.reshare({
  passphrase: hostSealingPassphrase,    // seals reshared.exportedBlob
});

// The returned handle is the new one — every subsequent sign() must
// use `reshared`, not `loaded`. The old `loaded` handle's share is
// functionally dead once the agent advances to the post-reshare
// session.
console.log('publicKey preserved:', reshared.publicKey.equals(loaded.publicKey));
// true — every on-chain address derived from publicKey stays valid

// Persist the new handle BEFORE discarding the old one. There is no
// undo after a successful reshare. The rotated state was sealed inside
// reshare() under `passphrase` and is ready on `reshared.exportedBlob`.
await secureStorage.put('signer-state', reshared.exportedBlob!);

Sealed-bundle reshare

opts.recovery is the knob: pass it, and the rotated handle returns with a fresh recoveryBundle sealed under that credential — regardless of whether the source handle had a bundle, was hydrated via load() (where the bundle reference was dropped at hydrate time), or was originally created with RecoveryKind.Noop (opt-in seal at reshare time).

When the source handle was created with a non-Noop recovery clause (RecoveryKind.Password, RecoveryKind.Social, etc.), reshare() rotates the share AND re-seals a fresh recovery bundle under the same credential. Pass the credential as opts.recovery:

const reshared = await loaded.reshare({
  recovery: { kind: RecoveryKind.Password, password: 'correct horse battery staple' },
  passphrase: hostSealingPassphrase,    // seals reshared.exportedBlob
});

// The returned handle carries BOTH a freshly-sealed export blob
// (`reshared.exportedBlob`) AND a freshly-sealed recovery bundle
// (`reshared.recoveryBundle`) bound to the rotated share. Persist
// both in place of the old ones — the OLD bundle no longer opens
// a working signer.
await secureStorage.put('signer-state', reshared.exportedBlob!);
await secureStorage.put('recovery-bundle', reshared.recoveryBundle!);

If you omit opts.recovery on a handle that has a recoveryBundle, reshare rejects up front with a clear error. If the supplied credential doesn't open the existing bundle, reshare also rejects — before any share rotation — so the old bundle stays valid.

To rotate the recovery credential ITSELF at the same time, run rotateRecoveryCredential() on the returned handle after reshare with the new credential.

What's shipped today

Signer shapereshare()
Single-agent 2-of-2, Noop recoveryPass {} (or omit opts). Opt-in seal: pass { recovery: ... } to also mint a fresh recoveryBundle on the rotated handle.
Single-agent 2-of-2 with sealed recoveryBundlePass { recovery: { kind: ..., ... } } — the new handle carries a freshly-sealed bundle bound to the rotated share.
Multi-agent (agent.length >= 2), Noop recoveryPass {}. Works at every threshold the cohort supports — threshold-2 uses the prefix-cohort path (only the primary agent participates alongside the device); threshold-N uses the relay driver that fans envelopes across every participating agent.
Hydrated via load() from a sealed-bundle sourcePass { recovery: { kind: ..., ... } } to preserve the bundle through the rotation. The hydrated handle's recoveryBundle field is null regardless of how the original handle was created, so supplying the credential is the explicit signal that you want the rotated handle to carry a freshly-sealed bundle in storage.

A handle hydrated via load() / restore() carries the original topology metadata; the parent EmbeddedAgent you call load() on MUST be configured against the same cohort size (URLs / API keys can change, but the count cannot) for multi-agent reshare to work on the rehydrated handle.


4. Emergency recovery — reconstruct the raw root private key

The three paths above keep you inside the MPC envelope — load and restore rehydrate a LoadedKeyShare so signing continues via the cohort, reshare rotates the share material across the cohort. Emergency recovery is the opposite operation: it reaches into the cohort, pulls each surviving agent's sealed shard, and reconstructs the wallet's raw root private key off-cluster — no MPC, no cohort, no further dependency on the SDK.

Reach for it only when the per-signer paths aren't sufficient:

  • The MPC infrastructure is permanently unreachable — cohort wipeout, sustained outage past your recovery SLA, vendor relationship ends, etc.
  • Migrate off MPC entirely — re-mint the recovered private key into an HSM, a hardware wallet, or a different cohort shape (different threshold, different parties, different curve).
  • Regulator-mandated key extraction — produce a single private key under a documented chain of custody for escrow or audit.
  • Disaster recovery drill — exercise the path on a non-production key so the runbook is rehearsed when you actually need it.

Important properties:

  • The device's share counts toward the quorum. loaded.exportRecoveryShard({ rsaPublicKeyPem, keyShareId }) on EmbeddedAgent emits a shard in the same RECB format the agent endpoint produces. The device shard combines with agent shards under RecoveryBundle.finalize — see 4a. Device + surviving agent(s) below for the "1 agent online + the user's device" pattern.
  • Requires a quorum — at least threshold-many parties must contribute a shard. Counts in either direction: threshold-many agents, OR (threshold − 1) agents + the device, OR any other combination that meets the threshold.
  • The shards are sealed in transit — each agent encrypts its shard under your RSA public key locally; no agent ever sees another agent's shard, and the network sees only ciphertext.
  • The output is the highest-sensitivity material in your system. Run the off-cluster combine + reconstruct on an air-gapped device, wipe the device after re-minting, and re-seal any persisted copy under a hardware-bound key.
  • Reshare rotates; emergency recovery extracts. loaded.reshare() produces a fresh share over the same root public key — the existing addresses keep working, but the private key is never assembled in cleartext. Emergency recovery assembles the private key in cleartext, on purpose, exactly once.
import { ClusterAgent, RecoveryBundle, Scheme, Curve } from '@zafeguard/mpc-sdk';

const surviving = await ClusterAgent.connect(host, port, apiKey);

// 1. Ask each surviving node for its sealed shard. The shards are
//    sealed to the integrator-provided RSA public key, so no node in
//    the cohort can read another node's shard in transit.
const shards: Uint8Array[] = await surviving.exportRecoveryBundle({
  keyShareId,
  rsaPublicKeyPem,                      // recipient's RSA pub for sealing
  peers: [                              // every OTHER node in the cohort
    { agentUrl: 'https://node-2.example', apiKey: NODE_2_KEY, keyShareId: ks2Id },
    // …
  ],
});

// 2. Off-line: combine the per-node shards into one RBUN container.
//    `tag` is integrator-chosen bytes mixed into the HKDF salt; it
//    must be presented again at recover time.
const bundle = RecoveryBundle.finalize({
  shards,
  bundlePublicKey: rsaPublicKeyPem,
  tag: Buffer.from('rotate-2026-q2', 'utf-8'),
});

// 3. Off-line on a cold device: reconstruct the root private key
//    using the RSA private key + the same tag.
const recovered = RecoveryBundle.recover({
  bundle,
  bundlePrivateKey: rsaPrivateKeyPem,
  rootPublicKey: keyShare.publicKey,
  tag: Buffer.from('rotate-2026-q2', 'utf-8'),
});
// recovered.privateKey, recovered.publicKey, recovered.curve

// From here you can re-mint the cohort into any new shape via
// Scheme.shamirSecretShare(...), import each share into the
// appropriate node, and discard the recovered private key.

The key share's secret material never appears in cleartext while it's in transit between nodes — each node seals its own shard locally under the recipient's RSA public key, and only the holder of the RSA private key can open the combined bundle.

Operationally: treat the recovered root private key as the highest-sensitivity material your system handles. Do this on an air-gapped device, wipe the device after re-minting, and re-seal any persisted copy under a hardware-bound key.

4a. Device + surviving agent(s) — recovery from partial cohort

When the cohort has lost too many agents to reach threshold from agents alone, the user's device share counts as one more party. The realistic disaster scenario for a 2-of-3 wallet — "we lost all but one agent, but the user's device is intact" — is recoverable via loaded.exportRecoveryShard(...) alongside agent.exportRecoveryBundle(...).

The device-side shard is byte-compatible with the agent shards: same RECB format, same RSA-OAEP+AES-GCM seal, same header commitments. RecoveryBundle.finalize accepts mixed device + agent shards in a single set.

import {
  ClusterAgent,
  EmbeddedAgent,
  RecoveryBundle,
  Scheme,
  Curve,
} from '@zafeguard/mpc-sdk';
import { generateKeyPairSync } from 'crypto';

// 1. Operator generates an RSA keypair (private key + tag stay in cold
//    storage; only the public key travels to cohort + device).
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const rsaPublicKeyPem  = publicKey  as string;
const rsaPrivateKeyPem = privateKey as string;
const tag = Buffer.from('partial-cohort-recovery-2026', 'utf-8');

// 2. Discover the agent's keyShareId — both shards must commit to the
//    same id or RecoveryBundle.finalize rejects the set.
const cluster = ClusterAgent.connect(host, port, apiKey);
const list = await cluster.keyShares.list();
const ks   = list.find((k) => k.publicKeyHex === loaded.publicKey.toString('hex'))!;
const keyShareId = ks.id;

// 3. Device shard — comes off the EmbeddedAgent handle.
const deviceShard = await loaded.exportRecoveryShard({
  rsaPublicKeyPem,
  keyShareId,
});

// 4. Surviving agent shard — one-call from ClusterAgent. `peers: []`
//    because the self-agent is implicit; for a wider cohort with
//    multiple surviving agents, list them as peers.
const [agentShard] = await cluster.exportRecoveryBundle({
  keyShareId,
  rsaPublicKeyPem,
  peers: [],
});

// 5. Finalize the two-shard set — 2-of-3 threshold satisfied (device + 1 agent).
const bundle = RecoveryBundle.finalize({
  shards: [deviceShard, agentShard],
  bundlePublicKey: rsaPublicKeyPem,
  tag,
});

// 6. Off-cluster on an air-gapped device: reconstruct the raw private key.
const recovered = RecoveryBundle.recover({
  bundle,
  bundlePrivateKey: rsaPrivateKeyPem,
  rootPublicKey: loaded.publicKey,
  tag,
});
// recovered.privateKey → use immediately to sign / re-mint, then wipe.

The device's shard:

  • Strips the license fingerprint before sealing — cold-storage artefact is not license-bound, recovery works after license expiry.
  • Uses the same RSA-OAEP+AES-256-GCM seal primitive the agent's /key-shares/{id}/recovery-bundle endpoint uses; RecoveryBundle.recover decrypts it with the matching RSA private key.
  • Commits to the same (public_key_hex, curve, threshold, max_shares, rsa_pubkey_fp_hex, key_share_id) as the agent shards in its cleartext header. RecoveryBundle.finalize cross-checks these before decrypting; any disagreement rejects the set.
  • Carries a node_id of device:<ed25519PubkeyHex> — distinct from agent node-ids so the audit trail records which party contributed which shard.

keyShareId must match what you pass to cluster.exportRecoveryBundle(...). Look it up via cluster.keyShares.list() and grep for the matching publicKeyHex. The device doesn't know the agent's local UUID for the share until you fetch it.

The device's share is mathematically equivalent to any other party's share — Shamir reconstruction doesn't care which party held which share, only that the threshold of distinct x_index values is reached. The cohort's threshold check at finalize time enforces the same property the underlying secret-sharing does.


What survives what

EventRecovery path
Process restart, same deviceload() from the blob; passphrase unlocks.
Device swap (new phone, fresh install)Pull the blob to the new device; load() with the same passphrase.
Lost the export() blob, kept the sealed recoveryBundlerestore() with the recovery credential.
Lost the export() blob AND the recovery bundleWallet is gone unless you also have a cold-recovery path. AES-GCM has no backdoor.
Routine share rotation (single-agent 2-of-2)reshare().
Lost one of N parties (multi-agent cohort)Cold-recover from the surviving quorum; re-mint the cohort with the recovered key.
Lost quorum-1 partiesWallet is gone unless your social-recovery layer can reconstruct.
Need a new threshold or cohort shapeCold-recover; re-mint via Scheme.shamirSecretShare(...).

Next

On this page