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.
| Event | Tool | Preserves on-chain address? |
|---|---|---|
| Process restart, same device | signer.load({ signerId, blob, passphrase }) | Yes |
| Device swap, user still has the export blob | signer.load({ signerId, blob, passphrase }) | Yes |
| Device lost, no export blob — rehydrate from the sealed recovery bundle | signer.load({ signerId, recoveryBundle, recovery }) | Yes |
| Rotate the recovery credential | loaded.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():
-
The export blob —
loaded.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." -
The recovery bundle —
loaded.recoveryBundle(aBuffer | null) is a sealed parallel copy of the device share, gated by the credential supplied in the recovery clause atcreate(). The bundle exists ONLY for 2-of-3 single-agent signers with a non-Nooprecovery 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, orNoopfor 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
restoremultiple 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.
- Re-
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
PasswordtoSocial(or vice versa) by supplying a differentnewRecovery.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
oldRecoveryrejects 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.
rotateRecoveryCredentialdoes not contact the cohort. The credential change is a re-seal of the same underlying share material under a new credential. - You can change
RecoveryKindhere too. Rotate fromPasswordtoSocialor vice versa by specifying a differentnewRecovery.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 loss | Path forward |
|---|---|
| Export blob still on the user's device + its passphrase still known | signer.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 too | The 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
restoreon 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.Socialenforces K-of-N cryptographically; your application layer collects the K fragments by whatever means and presents them to the SDK as a singlerecoveryBundleBuffer.
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
- Signing & Pool-Backed Latency — what the loaded handle does in normal operation.
- Dynamic Configuration — per-tier recovery policy.
- Embedded Agent → Recovery — the canonical SDK reference.
- Embedded Agent → Storage — the storage adapter contract for the export blob.
Key Ceremony
Run the distributed key generation ceremony, place shares across the cohort, pick a curve and recovery clause, and persist the artefacts your application needs after create() returns.
Signing & Pool-Backed Latency
Threshold signing with the LoadedKeyShare returned by EmbeddedAgent.create — the sign() flow, the presignature pool that cuts per-sign round-trips in half, auto-refill configuration, and the export/import handoff for cold-storage workflows.