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.
Key Ceremony
The key ceremony is the one-time event that brings a user's wallet into existence. It performs distributed key generation (DKG) across the configured cohort, places exactly one share on each participant, optionally seals a recovery bundle, and returns a LoadedKeyShare handle bound to the resulting state.
Two properties make this ceremony different from any single-key wallet creation:
- No participant ever sees the full private key. Each party generates randomness, contributes to the protocol, and ends up holding a partial share. The full key is never reconstructed at any point — not during ceremony, not during signing.
- Cohort topology is fixed at create time. Threshold, agent set, and curve are committed when you call
create. To rotate share material in place useloaded.reshare({ passphrase }); cohort-shape changes (replace a party, change the threshold) are not currently supported.
This page walks through the ceremony from EmbeddedAgent construction to the artefacts your backend persists.
→ For the canonical reference on every option, see @zafeguard/mpc-sdk → Embedded Agent → Quickstart and Topology.
Configure the cohort
The EmbeddedAgent constructor takes a JsEmbeddedAgentConfig:
import { EmbeddedAgent, Curve, BridgeKind } from '@zafeguard/mpc-sdk';
const signer = new EmbeddedAgent({
agents: [
{ baseUrl: 'https://signing-1.example.com', apiKey: process.env.ZG_AGENT_1! },
{ baseUrl: 'https://signing-2.example.com', apiKey: process.env.ZG_AGENT_2! },
],
threshold: 2,
curve: Curve.Secp256k1,
// Optional — opt-in auto-refill for the presignature pool.
presignaturePool: { autoRefillWhen: 5, autoRefillTo: 50 },
});| Field | Effect |
|---|---|
agent[] | The cloud cohort. Device sits at party 0; each entry becomes party 1, party 2, … in array order. Cohort size is 1 + agent.length. At least one agent is required. |
threshold | Signing quorum (t of n). Range 2..=(1 + agent.length). Default warm-cold shape is threshold: 2 ("device + any one agent"); higher thresholds tighten security at the cost of needing more agents online per signature. |
curve | The elliptic curve. Defaults to Curve.Secp256k1. A signer instance is single-curve; supporting both ECDSA chains and Solana means two signer instances. |
storage | Storage backend for the device share. Defaults to StorageKind.Auto (Keychain on iOS, Keystore on Android, encrypted file on Node, IndexedDB+CryptoKey on web). |
bridge | Transport. Defaults to BridgeKind.Https. |
presignaturePool | Opt-in auto-refill policy. { autoRefillWhen, autoRefillTo } — both required together. After a sign() drops the pool to autoRefillWhen or below, the SDK kicks a background mint up to autoRefillTo. Omit to manage the pool manually. |
Choosing the cohort size and threshold
Three patterns cover most production deployments:
(1 device + 1 agent, threshold 2)— the simplest 2-of-2 wallet. Both parties must sign. Use for starter tiers where simplicity matters more than fault tolerance. This shape supports the sealed recovery bundle (see Recovery).(1 device + 2 agents, threshold 2)— device + any one agent. Survives one agent going offline. Default for consumer apps. Recovery clause must beNoop— the additional agents themselves ARE the recovery surface.(1 device + 3 agents, threshold 3)— device + any two agents. Survives one agent plus regional infrastructure issues. Default for premium tiers. Recovery clause must beNoop.
→ See Embedded Agent → Topology for the formal trade-off.
Mixing built-in and self-hosted agents
A common production pattern uses two cloud agents on Zafeguard's built-in infrastructure plus one agent inside your own VPC:
const signer = new EmbeddedAgent({
agents: [
{ baseUrl: 'https://built-in-1.zafeguard.com', apiKey: process.env.ZG_BUILTIN_1! },
{ baseUrl: 'https://built-in-2.zafeguard.com', apiKey: process.env.ZG_BUILTIN_2! },
{ baseUrl: 'https://mpc.your-internal.example', apiKey: process.env.ZG_SELF_HOSTED! },
],
threshold: 3,
curve: Curve.Secp256k1,
});An attacker who compromises Zafeguard's built-in agents still cannot sign without also compromising your infrastructure — an independence boundary you can defend separately.
Run the ceremony
signer.create drives the DKG protocol with the configured cohort and returns a LoadedKeyShare:
import { RecoveryKind } from '@zafeguard/mpc-sdk';
const loaded = await signer.create({
signerId: `user-${userId}`,
recovery: { kind: RecoveryKind.Password, password: userPassphrase },
passphrase: exportPassphrase, // seals loaded.exportedBlob — persist directly
// Optional — forwarded to the agent's pre_dkg webhook for your audit pipeline.
policyContextJson: JSON.stringify({
productSurface: 'mobile-app-v2',
onboardingFlow: '2026-spring',
}),
});Three things happen inside this call, in order:
- DKG protocol execution. The SDK opens an authenticated channel to each cohort agent. The parties run the multi-round DKG protocol, exchanging commitments and zero-knowledge proofs. At the end, each party holds exactly one share; the device holds its own share.
- Share persistence on the device. The device share is sealed into the configured storage backend (Keychain on iOS, Keystore on Android, IndexedDB+CryptoKey on web, encrypted file on Node).
- Recovery seal (if applicable). For the 2-of-3 single-agent shape with a non-
Nooprecovery clause, the SDK produces a sealedrecoveryBundle— opaque bytes you persist out-of-band to use withsigner.load(...)on a fresh device.
signerId is required and must be non-empty; an undefined or empty value rejects with InvalidSignerId before any network side effect runs.
The returned LoadedKeyShare exposes:
loaded.signerId; // string — your application-scoped ID
loaded.publicKey; // Buffer — compressed public key, used to derive addresses
loaded.curve; // Curve — Curve.Secp256k1 or Curve.Ed25519
loaded.threshold; // number
loaded.participants; // number — cohort size
loaded.recoveryBundle; // Buffer | null — sealed bundle (single-agent + non-Noop only)There is no auto-derived multi-chain address surface on the handle. Addresses are computed from loaded.publicKey using the standard derivation paths for each chain — either in your application code or through the workflow components COMPUTE_PUBLIC_KEY and COMPUTE_EVM_ADDRESS (the Smart Account Flow shows the workflow-side derivation pattern).
Picking a recovery clause
The recovery clause passed to create controls whether a sealed recovery bundle is produced and what credential opens it. Real options are discriminated by RecoveryKind:
import { RecoveryKind } from '@zafeguard/mpc-sdk';
// No sealed bundle. Losing the export() blob ends the wallet.
recovery: { kind: RecoveryKind.Noop }
// Passphrase-sealed bundle. User memorises or writes down the passphrase.
recovery: { kind: RecoveryKind.Password, password: 'twelve word seed phrase' }
// Shamir-split bundle. Distribute across trusted contacts (any K of N restore).
recovery: {
kind: RecoveryKind.Social,
socialThreshold: 3,
socialRecipientsHex: [contact1X25519Hex, contact2X25519Hex, contact3X25519Hex, contact4X25519Hex],
}
// RSA-OAEP sealed to a custodian's public key.
recovery: { kind: RecoveryKind.Custodial, custodialPublicKeyPem: hsmPublicKeyPem }The sealed-bundle variants only apply to the 2-of-3 single-agent shape; multi-agent cohorts (cohort size 3+) require Noop because the additional agents themselves are the recovery surface.
→ See Embedded Agent → Recovery for the full design rationale.
Persisting ceremony output
After create returns, your application needs to persist both the device-share blob and (if produced) the recovery bundle.
// The export blob — encrypted device share + transport keys + cached cohort
// metadata, sealed inside create() under the create-time `passphrase`.
// scrypt(passphrase) → AES-256-GCM(salt || nonce || ciphertext || tag).
// Already ready on `loaded.exportedBlob`; no second `loaded.export(...)` call
// needed for the routine path.
await secureStorage.put(`signer:${userId}`, loaded.exportedBlob!);
// The recovery bundle (when present) — parallel sealed copy of the device share,
// gated by the recovery clause credential. Persist OUT OF BAND from the export blob.
if (loaded.recoveryBundle) {
await cloudBackup.put(`recovery:${userId}`, loaded.recoveryBundle);
}
// Public material your backend can persist freely — nothing private here.
await db.users.update(userId, {
signerId: loaded.signerId,
publicKeyHex: loaded.publicKey.toString('hex'),
curve: loaded.curve,
threshold: loaded.threshold,
participants: loaded.participants,
});The export blob is the canonical state for "rehydrate on the same device or on a fresh device the user still controls." The recovery bundle is the canonical state for "rehydrate on a fresh device where the user lost the export blob, using the recovery credential." Persist them with independent durability and access patterns — losing both ends the wallet.
The export blob also carries the presignature pool snapshot, so a device that pre-minted entries can resume signing against them after restart.
Adding a second curve for Solana support
A single EmbeddedAgent instance is single-curve. To support both EVM/Bitcoin and Solana, construct two signer instances and run two ceremonies under one signerId namespace at the application layer:
const ecdsaSigner = new EmbeddedAgent({ agent, threshold: 2, curve: Curve.Secp256k1 });
const ecdsaLoaded = await ecdsaSigner.create({
signerId: `user-${userId}-secp256k1`,
recovery: { kind: RecoveryKind.Password, password },
passphrase: exportPassphrase,
});
const ed25519Signer = new EmbeddedAgent({ agent, threshold: 2, curve: Curve.Ed25519 });
const ed25519Loaded = await ed25519Signer.create({
signerId: `user-${userId}-ed25519`,
recovery: { kind: RecoveryKind.Password, password },
passphrase: exportPassphrase,
});The two signers are independent — different shares, different cohort sessions, different recovery state. Your application code joins them under one logical wallet for the user. The agent connection list can be identical; the DKG runs are separate.
What the user sees during the ceremony
The DKG protocol is multiple network round-trips and a few hundred milliseconds of cryptographic work on the device. For UX:
- Show a "creating your wallet" screen for the duration. The ceremony is irreducible.
- Derive and display addresses on success — call
COMPUTE_EVM_ADDRESS(or the relevant helper) againstloaded.publicKeyand show the user the addresses they will receive funds at. - Do not gate the wallet on recovery acknowledgement. Recovery clause persistence happens during
createitself; any guardian-side flow you ship on top of the SDK (notifying contacts, requesting backups of the recovery bundle) can run asynchronously after the ceremony returns.
Next
- Recovery — restoring with the recovery bundle, rotating the credential, and the four recovery lifecycle events.
- Signing & Pool-Backed Latency — using the handle the ceremony returns.
- Dynamic Configuration — per-tier and per-region ceremony shape.
- Embedded Agent reference — every method, every option, full signatures.
Overview
Architecture overview for shipping a non-custodial embedded wallet inside your mobile or web app using Zafeguard MPC SDK and the workflow canvas.
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.