Cluster Agent

API reference

Complete ClusterAgent client API — connect, sessions, key shares, presignatures, recovery bundles. Every method, every option.

API reference — ClusterAgent client

ClusterAgent is the lower-level client in @zafeguard/mpc-sdk. It exposes the full HTTPS surface of an MPC Agent cluster node so backends can drive ceremonies directly (no embedded device share). Pair this page with Integration patterns for context on when to reach for ClusterAgent over EmbeddedAgent.

import { ClusterAgent } from '@zafeguard/mpc-sdk';

const agent = ClusterAgent.connect(host, port, apiKey, /* tls */ true);

Direct HTTP access

ClusterAgent is a thin wrapper over the node's REST API. If you're integrating from a stack other than TypeScript, call that API directly.

  • Authentication. Every route except /health, /ready, and /metrics requires the API key, sent as either Authorization: Bearer <key> or x-api-key: <key>. (Authorization: ApiKey <key> is not accepted.)
  • Full spec. The node serves its own OpenAPI spec and Swagger UI — set swagger.enabled: true in the config, then open /swagger-ui (raw JSON at /swagger-ui/openapi.json). That spec is the authoritative list of routes, request/response schemas, and enum values.

A few wire-format details that are easy to miss when hand-rolling requests:

  • Mixed casing in one body. Request bodies are camelCase (keyId, maxShares, messageHashHex), but each entry of peers[] is snake_case: node_id, index, ed25519_pubkey, x25519_pubkey.
  • Curve values are uppercase on the wireSECP256K1, ED25519, P256, … (the all-lowercase form is also accepted; the mixed-case Secp256k1 is not).
  • peers[] is the full cohort. Its length must equal maxShares and it must include the node you're calling. Each peer's x25519_pubkey comes from that peer's GET /node/info.

The method reference below uses the SDK's camelCase field names throughout; the SDK handles the casing and header details for you.


ClusterAgent.connect(host, port, apiKey, tls?)

static connect(host: string, port: number, apiKey: string, tls = false): ClusterAgent

Open a client against a single agent node. Use tls: true for the HTTPS interface (the only safe option in production); false runs plain HTTP and is appropriate only for local-dev.

The returned ClusterAgent is reusable across many calls; one instance per (host, port) is enough.


agent.node — health + identity

agent.node.info():   Promise<AgentNodeInfo>
agent.node.health(): Promise<AgentHealthResponse>
agent.node.ready():  Promise<AgentReadyResponse>
MethodDescription
info()Static identity: nodeId, ed25519Pubkey, x25519Pubkey, license summary. Use for pinning peer keys before DKG.
health()Liveness probe — { status: 'ok' } when the process is running.
ready()Readiness probe — { status: 'ok' } once the DB schema has migrated, the intra-cluster transport is connected, and the license is verified.

agent.sessions — ceremony orchestration

agent.sessions.createKeyShare(req: CreateKeyShareSessionRequest):   Promise<Ceremony>
agent.sessions.createPresignature(req: CreatePresignatureSessionRequest): Promise<Ceremony>
agent.sessions.createSignMessage(req: CreateSignMessageSessionRequest):                 Promise<Ceremony>
agent.sessions.createReshare(req: CreateReshareSessionRequest):     Promise<Ceremony>

agent.sessions.list(phase?: string):  Promise<AgentSessionResponse[]>
agent.sessions.get(id: string):       Promise<AgentSessionResponse>
agent.sessions.cancel(id: string):    Promise<void>
agent.sessions.auditLogs(opts?: {...}): Promise<AgentAuditLogResponse[]>

The four create* methods kick off the four ceremony kinds. Each returns a Ceremony handle:

const session = await agent.sessions.createKeyShare({ /* ... */ });

// One-shot snapshot of the initial response:
const initial = session.raw();

// Poll until phase reaches Complete / Failed / Cancelled:
const terminal: AgentSessionResponse = await session.untilTerminal();

// Cancel mid-ceremony:
await session.cancel();

untilTerminal() resolves with AgentSessionResponse, the same shape returned from agent.sessions.get(id). Key fields:

FieldTypeWhen setWhat it carries
idstringAlwaysCluster-assigned session UUID. Use this as the sessionId for any follow-up cancel / auditLogs query.
phasestringAlwaysCurrent phase — 'Initialising' | 'AwaitingPeerCommitments' | 'AwaitingPeerShares' | 'Reconstructing' | 'Complete' | 'Failed' | 'Expired' | 'Cancelled'. Branch on this.
publicKeyHexstringAfter 'Complete' for DKG / reshare sessionsThe wallet's root public key hex. For DKG, this is the only handle you get back — keyShareId is NOT present in the DKG session response. Look up the local keyShareId separately via agent.keyShares.list() filtering by this publicKeyHex.
keyShareIdstringAfter 'Complete' for presign / sign sessions (NOT DKG)Cluster-assigned share id the presign / sign ceremony ran against. See keyId vs keyShareId below.
partialSignaturesstring[]After 'Complete' for sign sessionsThreshold-many base64 PSIG envelopes. Combine via Scheme.finalizeSignature(...) or Utils.finalizeSignature({...}).
errorstring | nullAfter 'Failed'Human-readable failure reason.

For DKG specifically, the public key is the canonical handle: terminal.publicKeyHex is the wallet's root public key. Look up the cluster's internal keyShareId with agent.keyShares.list(), filtering by that publicKeyHex. From then on, every presign / sign / export call uses keyShareId, not keyId.

keyId vs keyShareId

These are two distinct identifiers — confusing them is the most common cluster-side integration mistake:

IdentifierSet byStable acrossFirst appears on
keyIdThe caller, at createKeyShare time.The cohort. Every agent stores the same keyId for this wallet.CreateKeyShareSessionRequest.keyId
keyShareIdThe cluster, after DKG finalises.The local agent only — every cluster node has its own keyShareId for the same wallet.AgentKeyShareResponse.id (returned from agent.keyShares.list())

Use keyId as your durable application-level identifier (the value you'd save next to a user record). Use keyShareId when calling any agent method that operates on the local share record — presign, sign, export, delete. The two values are linked by the wallet's publicKeyHex, which both expose.

RequestRequired fields
CreateKeyShareSessionRequestkeyId, curve, threshold, maxShares, peers[]. Optional nizkProof, policyContext, ttlSecs.
CreatePresignatureSessionRequestkeyShareId, peers[]. Optional sourceDkgSessionId (recommended), policyContext, ttlSecs.
CreateSignMessageSessionRequest (one-shot)keyShareId, peers[], messageHashHex. Optional derivationPath, partialSigExpiresInSecs.
CreateReshareSessionRequestkeyId, curve, threshold, maxShares, myIndex, peers[] (new set), reshareOldPeers[], reshareOldThreshold. Optional reshareMyOldIndex, sourceDkgSessionId, existingPublicKeyHex.

createSignMessage runs presigning + signing back-to-back inside the same ceremony — use it for one-shot signing with no presignature pool. To sign against a pre-minted presignature, call agent.presignature.sign(presigId, req) against each cluster node directly: every node holds its own copy of the presignature locally and produces its partial signature independently, so no multi-party ceremony is required. See Hot Wallet — cluster signing flow for the decision guidance.

agent.sessions.list(phase) filters by current phase ('Initialising', 'AwaitingPeerCommitments', 'AwaitingPeerShares', 'Reconstructing', 'Complete', 'Failed', 'Expired', 'Cancelled').

Audit logs — for compliance, forensics, and SIEM export

agent.sessions.auditLogs({ sessionId?, limit?, eventType?, failuresOnly? }) returns the complete event trail for one session (or every session within limit). Each row is an AgentAuditLogResponse carrying the session id, event type, the agent node id that emitted it, the wall-clock timestamp, and a free-form payload describing the event (DKG round transitions, partial-signature production, ceremony completion, every peer round-trip).

Use it for:

  • Regulated-custody audit trails (MiCA / SOC 2 / PCI-DSS). Every signing ceremony writes a row. Export to your WORM-compliant storage on a cadence (hourly, daily) and you have a 5+ year audit trail that survives vendor changeover. Query eventType to filter for 'SignMessageCompleted' and similar terminal events when assembling the auditor-facing report.
  • Live SIEM streaming. Poll the log tail by passing limit + a since-timestamp filter (track the last id you observed). Forward each new row into Splunk / Datadog / your SIEM of choice. The agent's nodeId plus the row's hash chain form the cryptographic evidence chain — auditors verify the log is unforged by replaying the per-event signatures against the agent's published node-info pubkey from agent.node.info().
  • Failure forensics. failuresOnly: true filters to ceremonies that ended in Failed/Expired/Cancelled. Pair with agent.sessions.get(id) to fetch the terminal error string for each.
// Export the last 24h of audit events to a WORM bucket, signed by the agent.
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const rows = await agent.sessions.auditLogs({ limit: 100_000 });
const recent = rows.filter((r) => Date.parse(r.timestamp) > now - dayMs);
await wormBucket.put(`audit/${now}.jsonl`, recent.map(JSON.stringify).join('\n'));

For multi-region custody with multiple cluster nodes, query each ClusterAgent independently — every node maintains its own local audit log; the union is the cluster-wide view.

Cluster-wide reshare orchestration

agent.sessions.createReshare(...) is the cluster-side counterpart to loaded.reshare() from EmbeddedAgent. Use it when the cohort is entirely cluster-held (no device party) and you need every cluster node's share to rotate in one coordinated ceremony — typical after an agent compromise, an API-key rotation event, or a scheduled hygiene rotation.

CreateReshareSessionRequest takes keyId, curve, the new threshold / maxShares (which can match the old ones for a same-shape rotation, or differ for topology change), myIndex (this node's slot in the new cohort), and peers[] (the new set of cohort URLs + api keys). reshareOldPeers[] and reshareOldThreshold describe the existing cohort the rotation runs against; the SDK orchestrates the cross-set ceremony in one call.

The wallet's publicKeyHex is preserved by design — the same on-chain address signs after rotation. Until untilTerminal() resolves with 'Complete', every old share remains valid; on Complete, the agents atomically swap to the new shares and any subsequent presign/sign against the old shares rejects.

Fleet rotation. For a wallet platform managing tens of thousands of cluster-held wallets, the SDK provides per-wallet reshare only — orchestration (queueing, retries, per-wallet idempotency markers, agent rate-limit caps) is the integrator's job. A reasonable shape: one job per keyId, a reshare_state column tracking progress, workers loading the wallet → calling createReshare → persisting the new publicKeyHex confirmation under a per-wallet lock, and a fleet-wide concurrency cap matched to the agent's per-second presign throughput.


agent.keyShares — share lifecycle

agent.keyShares.list(limit?: number):     Promise<AgentKeyShareResponse[]>
agent.keyShares.get(id: string):          Promise<AgentKeyShareResponse>
agent.keyShares.delete(id: string):       Promise<void>
agent.keyShares.export(id: string, opts: ExportKeyShareOptions): Promise<AgentExportKeyShareResponse>
agent.keyShares.import(req: ImportKeyShareRequest): Promise<unknown>

keyShares.export

Export a key share as a sealed portable blob for migration to another agent (different cluster, different license).

{
  cryptoMode: 'RSA' | 'P256';

  // Required when cryptoMode === 'RSA':
  publicKeyPem?: string;     // PKCS#8 PEM, ≥ 2048-bit RSA pubkey

  // Required when cryptoMode === 'P256':
  userPublicKeyBytes?: Uint8Array;  // P-256 public point (65-byte uncompressed SEC1)
}
  • RSA mode produces an RSA-OAEP+AES-GCM sealed blob. The standard choice for inter-cluster migration.
  • P256 mode produces a P-256 ECIES sealed blob. Used for iOS Secure Enclave handoff where the recipient's key lives in the secure element and exposes only its P-256 public point.

The blob is license-bound: it can only be imported on an agent holding the SAME license cert. For license-independent recovery (cold backup, cross-tenant migration), use exportRecoveryBundle below.

keyShares.import

Import a key share via the forward-secret handshake. The keyShare field in req must be encrypted with the ephemeral server pubkey from agent.importHandshake(). The migrateKeyShare helper wraps the full export → handshake → wrap → import flow into one call — prefer that to manual orchestration.


agent.presignature — presig lifecycle

agent.presignature.list(opts?: { publicKeyHex?, limit? }): Promise<AgentPresignatureResponse[]>
agent.presignature.get(id: string):                       Promise<AgentPresignatureResponse>
agent.presignature.delete(id: string):                    Promise<void>
agent.presignature.export(id: string, keyShareId: string, publicKeyPem: string): Promise<AgentExportPresignatureResponse>
agent.presignature.import(req: ImportPresignatureRequest): Promise<unknown>
agent.presignature.sign(presigId: string, req: SignRequest): Promise<AgentSignResponse>

presignatures.sign

The canonical sign path. Consumes one presignature and returns THIS node's partial signature for the given message hash:

const partial = await agent.presignature.sign(presigId, {
  messageHashB64: Buffer.from(messageHash).toString('base64'),
  derivationPath: [],                       // optional BIP-32 path
  recipientPublicKeyPem: undefined,         // optional RSA pubkey for end-to-end encryption
});

Returns { presigId, keyShareId, publicKeyHex, partialSignatureB64, isEncrypted, expiresAt }.

  • partialSignatureB64 is base64 of either the cleartext PSIG envelope (when recipientPublicKeyPem was omitted) or an RSA-OAEP+AES-GCM mpcblob wrapping it (when supplied).
  • Branch on isEncrypted. For the encrypted path, pipe through PartialSignatures.unwrap(...) to get back to the cleartext base64.
  • Feed the cleartext base64 directly into Scheme.finalizeSignature(...) after collecting threshold-many partials — the cluster never finalizes; the client combines locally.

presignatures.export / presignatures.import

Per-presig migration between clusters. Same handshake pattern as keyShares.import; the migratePresignature helper wraps the full flow.


agent.exportRecoveryBundle(args) — cold recovery

agent.exportRecoveryBundle({
  keyShareId: string;
  rsaPublicKeyPem: string;            // recipient's RSA pubkey
  peers: Array<{
    agentUrl: string;
    apiKey?: string;
    keyShareId: string;
  }>;
}): Promise<Uint8Array[]>

Asks THIS agent + every supplied peer for their per-node sealed shard, then returns the array of bundles (one entry per participating agent, this agent first). Differs from keyShares.export:

  • Multi-party. Requires ≥ threshold participating agents. Partial success is rejected by design — if fewer than threshold peers respond, the promise rejects with "export-recovery-bundle: only N of M peers responded (need at least T)" and no partial state is returned. List ALL surviving agents in peers[]; the cluster collects shards from whoever responds and rejects the call only when the count drops below threshold.
  • Not license-bound. The recovered private key works without a live agent or active license — designed for off-line cold recovery in disaster scenarios. RecoveryBundle.recover(...) also runs license-free, so the air-gapped recovery device does not need to call Mpc.init(...) first.

Combine the bundles via RecoveryBundle.finalize(...) then unseal with RecoveryBundle.recover(...) to get back the root private key. See Embedded Agent — Cold recovery for the full walkthrough.


agent.importHandshake()

agent.importHandshake(): Promise<AgentHandshakeResponse>

Allocate a one-shot ephemeral RSA-2048 keypair on the server for a single import operation. The handshake is consumed atomically on first import and expires after 60 seconds. Used internally by migrateKeyShare / migratePresignature; callers normally don't invoke directly.


Cross-cluster migration

Moving a key share or a presignature between two clusters. The destination cluster owns the migration call — it's a method on the destination ClusterAgent that takes the sealed export blob, the caller's RSA private key, and a minimal metadata stub.

Both clusters must share the same license certificate. The sealed blob is bound to the issuer's license fingerprint; importing it on a cluster running a different license rejects with "license fingerprint mismatch". If the destination runs a different license, the only path is emergency cold recovery (raw private key extraction) followed by re-DKG on the new cluster — a destructive operation that needs board approval and rewrites the cohort.

destAgent.migrateKeyShare(exportBlob, privateKeyPem, meta)

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

// 1. The caller mints a one-shot RSA keypair (e.g. via Node `crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })`).
//    The public key is handed to the SOURCE cluster; the private key stays on the migration runner.
const { publicKeyPem, privateKeyPem } = await mintRsaKeypair();

// 2. On the source: pull the wallet's cluster-side `keyShareId` (the caller's `keyId` is not used here).
const sourceAgent  = ClusterAgent.connect('us-node-1.example', 443, US_API_KEY, true);
const sourceShares = await sourceAgent.keyShares.list();
const sourceShare  = sourceShares.find(s => s.keyId === 'eu-customer-wallet-prod');
if (!sourceShare) throw new Error('wallet not found on source cluster');

// 3. Source seals the share with the caller's RSA pubkey.
const exportResp = await sourceAgent.keyShares.export(sourceShare.id, {
  cryptoMode: 'RSA',
  publicKeyPem,
});

// 4. Destination drives the migration in one call. The destination unseals,
//    re-wraps under its own ephemeral handshake key, and imports.
const destAgent = ClusterAgent.connect('eu-node-1.example', 443, EU_API_KEY, true);
await destAgent.migrateKeyShare(
  exportResp.blob,                              // sealed bytes from step 3
  privateKeyPem,                                // caller's RSA private key
  { curve: sourceShare.curve, publicKeyHex: sourceShare.publicKeyHex },
);

// 5. Confirm: list shares on the destination, locate by publicKeyHex.
const destShares = await destAgent.keyShares.list();
const destShare  = destShares.find(s => s.publicKeyHex === sourceShare.publicKeyHex);
// destShare.id is the destination cluster's local keyShareId — use it for
// all subsequent presign / sign / export calls against destAgent.

The migrate call runs the full pipeline server-side: unwrap with the caller's RSA private key → handshake against the destination → re-wrap under the handshake key → import. The caller never sees plaintext key material in the application process; only the source export blob and the destination handshake re-wrap pass through the SDK in opaque form.

migrateKeyShare is a method on the destination ClusterAgent — the source agent is queried separately for the export blob. The call resolves once the import has settled; an exception is thrown if the handshake expires, the license fingerprints differ, the publicKeyHex in meta doesn't match the unsealed share, or the destination already holds a share with the same id.

destAgent.migratePresignature(exportBlob, privateKeyPem, meta)

Same pattern for a single presignature entry. Source: sourceAgent.presignature.export(presigId, keyShareId, publicKeyPem) returns the sealed blob; destination: destAgent.migratePresignature(blob, privateKeyPem, { keyShareId, curve, publicKeyHex }). The keyShareId in meta is the destination cluster's local key-share id (the destination must already hold the wallet's key share, e.g. via a prior migrateKeyShare).

Lifecycle around migration

  • Source-side cleanup is the caller's responsibility. migrateKeyShare does not delete the source share. To remove it after a successful migration, call sourceAgent.keyShares.delete(sourceShare.id) against the source cluster.
  • keyShares.delete() operates on the local agent only. Deleting on one node removes that node's local share record; it does not propagate cluster-wide. For a 3-of-3 cluster wallet, run delete() against each surviving cohort node (typically a small loop over your peer list).
  • The wallet's publicKeyHex is preserved by design. Migration moves share material — the root key never reconstructs, so every on-chain address derived from the wallet remains stable.
  • Presignature pools are not migrated automatically. migrateKeyShare moves only the share. Any presignatures pre-minted on the source cluster stay there; mint a fresh pool on the destination after migration, or migrate entries individually with migratePresignature.

RecoveryBundle.verify(bundles, rsaPublicKeyPem)

Cross-node consistency check on the array of bundles returned by exportRecoveryBundle. Does NOT decrypt — useful to validate bundles before storing them long-term.

RecoveryBundle.finalize({ shards, bundlePublicKey, tag, passphrase? })

Combines per-node shards into a single RBUN container. Optional passphrase seals the output under a passphrase (AES-256-GCM); omit for an unsealed bundle that RecoveryBundle.recover can open with just the RSA private key.

RecoveryBundle.recover({ bundle, bundlePrivateKey, rootPublicKey, tag, passphrase? })

Off-line reconstruction. Returns { curve, privateKey, publicKey, chainCode? }. Pair with KeyUtils.deriveChildPrivateKey for BIP-32 child key derivation off-line.


Error model

All ClusterAgent methods return rejected Promises with Error instances when the agent rejects the request or the network fails. Common shapes:

ErrorCause
agent unreachable: <details>Network / DNS / connection refused.
agent returned 401API key rejected.
agent returned 403Policy webhook denied the operation.
agent returned 429: rate limit exceededPer-API-key rate limit hit. Wait or use a different key.
agent returned 500: <message>Internal agent error. Check the agent's logs; report to operators.
Ceremony failed: <reason>A ceremony reached Phase.Failed. The reason comes from the agent.

See also

On this page