Skip to main content

Export, Rotation & Restore

This page covers the operational lifecycle of MPC shards after the ceremony: getting them off the nodes, rotating custodians without exposing key material, and importing shards on demand for signing.

Prerequisites

This page uses helpers defined in Custodian Setup: envelopeEncrypt, envelopeDecrypt, and the age-encryption package.


Section 1 — Export Shards and Clear Nodes (Just-in-Time Model)

Do not leave key shares on MPC nodes permanently

The strongest security posture is to treat MPC nodes as transit infrastructure, not storage. Immediately after the ceremony, export every shard with deleteAfterExport: true so nodes are empty at rest.

A node that holds no key shares cannot be compromised to extract one.

import * as age from 'age-encryption';
import * as fs from 'fs';
import { WorkspaceClient, ComponentModule } from 'caller-sdk';

const workspace = new WorkspaceClient({ apiKey: process.env.WR_API_KEY! });

async function exportAndClearAllNodes(
keyId: string,
custodians: Array<{ name: string; agePublicKey: string }>,
servers: Array<'OFFICIAL_1' | 'OFFICIAL_2' | 'OFFICIAL_3'>,
): Promise<void> {
const results = await Promise.all(
servers.map(async (server, i) => {
const custodian = custodians[i];
const { wrappedKeyShare, curve } = await workspace
.call(ComponentModule.EXPORT_KEY_SHARE, {
keyId,
ageRecipient: custodian.agePublicKey,
deleteAfterExport: true, // ← node is empty after this call
})
.promise();
return { custodian: custodian.name, server, wrappedKeyShare, curve };
}),
);

for (const { custodian, server, wrappedKeyShare, curve } of results) {
fs.writeFileSync(`./${custodian}-shard.json`, JSON.stringify({
custodian, keyId, server, curve,
wrappedKeyShare,
exportedAt: new Date().toISOString(),
}, null, 2), { mode: 0o600 });
console.log(`[${custodian}] Shard from ${server} exported — node cleared.`);
}
}
Key concept — deleteAfterExport: true

This flag atomically removes the shard from the node after returning it to you. The node's storage is now empty — there is no cryptographic material for an attacker to extract.

Think of the node as a signing terminal, not a vault. You bring the shard to it, it signs, you take the shard back.

Institutional policy — 3-2-1 shard distribution
  • 3 custodians holding one shard each
  • 2 storage media per custodian (e.g. encrypted USB + printed QR code)
  • 1 off-site or geographically remote location per custodian

With nodes empty at rest, an attacker must simultaneously breach 2 custodians' cold storage and their SSS guardians and have access to a running node. This is the highest practical security bar.


Section 2 — Local Age Helpers

All re-wrapping operations use these two local functions. The age private key is decrypted in RAM and never transmitted over the network.

// ─────────────────────────────────────────────────────────────────────────────
// Decrypt a wrappedKeyShare using a custodian's age private key — all local.
// ─────────────────────────────────────────────────────────────────────────────

export async function ageDecrypt(
wrappedKeyShare: string, // age-encrypted blob from EXPORT_KEY_SHARE
agePrivateKey: string, // "AGE-SECRET-KEY-1..." — decrypted by custodian passphrase
): Promise<Uint8Array> {
const ciphertext = Buffer.from(wrappedKeyShare, 'base64');
const identity = new age.X25519Identity(agePrivateKey);
const decrypter = new age.Decrypter();
decrypter.addIdentity(identity);
return decrypter.decrypt(ciphertext); // raw shard bytes — keep in RAM only
}

// ─────────────────────────────────────────────────────────────────────────────
// Encrypt raw shard bytes to a recipient's age public key — all local.
// ─────────────────────────────────────────────────────────────────────────────

export async function ageEncrypt(
rawShardBytes: Uint8Array,
recipientPublicKey: string, // "age1..." — new custodian's or node's public key
): Promise<string> {
const recipient = new age.X25519Recipient(recipientPublicKey);
const encrypter = new age.Encrypter();
encrypter.addRecipient(recipient);
const ciphertext = await encrypter.encrypt(rawShardBytes);
return Buffer.from(ciphertext).toString('base64');
}

Section 3 — Local Re-wrapping for Custodian Rotation

When a custodian leaves or their age identity needs rotation, re-encrypt the shard to the new custodian's public key — entirely on a local machine.

Do not use REWRAPPING_KEY_SHARE for institutional custody

REWRAPPING_KEY_SHARE sends the old age private key to the White Rabbit API to perform the re-encryption. For institutional use, the age private key must never leave the custodian's local device.

Comparison:

MethodWhere age key exists
REWRAPPING_KEY_SHARELocal RAM → HTTPS → API server RAM → response
Local re-wrap (below)Local RAM only — never leaves the process
export async function localRewrapShard(
shardFile: string,
oldCustodianIdentityFile: string,
oldCustodianPassphrase: string,
newCustodian: { name: string; agePublicKey: string },
): Promise<string> {
// Step 1: Decrypt age private key locally using custodian's passphrase
const identity = JSON.parse(fs.readFileSync(oldCustodianIdentityFile, 'utf8'));
const agePrivateKey = envelopeDecrypt(identity.encryptedPrivateKey, oldCustodianPassphrase);

// Step 2: Load the shard
const shardRecord = JSON.parse(fs.readFileSync(shardFile, 'utf8'));
const wrappedKeyShare = shardRecord.wrappedKeyShare;

// Step 3: Decrypt locally — raw bytes exist only in RAM, never written to disk
const rawShardBytes = await ageDecrypt(wrappedKeyShare, agePrivateKey);

// Step 4: Re-encrypt to new custodian's public key — all local, no network
const rewrappedKeyShare = await ageEncrypt(rawShardBytes, newCustodian.agePublicKey);

// Step 5: Zero raw bytes from memory immediately
rawShardBytes.fill(0);

// Step 6: Write the new shard file
const newShardFile = `./${newCustodian.name}-shard.json`;
fs.writeFileSync(newShardFile, JSON.stringify({
custodian: newCustodian.name,
keyId: shardRecord.keyId,
curve: shardRecord.curve,
wrappedKeyShare: rewrappedKeyShare,
rotatedAt: new Date().toISOString(),
rotatedFrom: identity.custodian,
}, null, 2), { mode: 0o600 });

console.log(`Re-wrapped locally: ${identity.custodian}${newCustodian.name}`);
return rewrappedKeyShare;
}
Institutional policy — rotation triggers
  • Scheduled: every 90 days
  • Personnel change: within 24h of custodian departure
  • Suspected compromise: immediately

Always verify the new custodian can decrypt their shard before destroying the old identity.


Section 4 — Restore (Just-in-Time Import for Signing)

Import a shard before a signing session, sign, then delete the shard again. The age private key is re-wrapped locally for the node before import — it never travels over the network.

// ─────────────────────────────────────────────────────────────────────────────
// Re-wrap the shard for a specific node, then import it.
// Custodian's age private key: local RAM only.
// ─────────────────────────────────────────────────────────────────────────────

async function rewrapAndImport(
wrappedKeyShare: string,
custodianAgePrivateKey: string,
targetServer: 'OFFICIAL_1' | 'OFFICIAL_2' | 'OFFICIAL_3',
): Promise<string> {
// Fetch the node's age PUBLIC key — safe to request over the network
const { recipientKey } = await workspace
.call(ComponentModule.GET_NODE_RECIPIENT_KEY, {})
.promise();

// Decrypt the shard locally
const rawShardBytes = await ageDecrypt(wrappedKeyShare, custodianAgePrivateKey);

// Re-encrypt for the node locally
const rewrappedForNode = await ageEncrypt(rawShardBytes, recipientKey);

// Zero raw bytes — were only ever in RAM
rawShardBytes.fill(0);

// Import — node decrypts with its own private key and stores the share
const { keyId } = await workspace
.call(ComponentModule.IMPORT_KEY_SHARE, {
wrappedKeyShare: rewrappedForNode,
})
.promise();

console.log(`Shard imported to ${targetServer}. keyId: ${keyId}`);
return keyId;
}

// ─────────────────────────────────────────────────────────────────────────────
// Full restore — called by a custodian on their local machine.
// ─────────────────────────────────────────────────────────────────────────────

export async function restoreShardToNode(
custodianIdentityFile: string,
shardFile: string,
custodianPassphrase: string,
targetServer: 'OFFICIAL_1' | 'OFFICIAL_2' | 'OFFICIAL_3',
): Promise<string> {
const identity = JSON.parse(fs.readFileSync(custodianIdentityFile, 'utf8'));
const agePrivateKey = envelopeDecrypt(identity.encryptedPrivateKey, custodianPassphrase);
const { wrappedKeyShare } = JSON.parse(fs.readFileSync(shardFile, 'utf8'));

const keyId = await rewrapAndImport(wrappedKeyShare, agePrivateKey, targetServer);

// Zero the age private key from memory as soon as we're done
(agePrivateKey as any) = null;

return keyId;
}

// ─────────────────────────────────────────────────────────────────────────────
// After signing: export + delete the shard — node goes empty again.
// ─────────────────────────────────────────────────────────────────────────────

export async function clearNodeAfterSigning(
keyId: string,
server: 'OFFICIAL_1' | 'OFFICIAL_2' | 'OFFICIAL_3',
): Promise<void> {
// Temporary identity — we only need deletion, not the exported blob
const tempIdentity = new age.X25519Identity();
await workspace
.call(ComponentModule.EXPORT_KEY_SHARE, {
keyId,
ageRecipient: tempIdentity.recipient().toString(),
deleteAfterExport: true,
})
.promise();
console.log(`Node ${server} cleared after signing session.`);
}
Key concept — node recipient key is a public key

GET_NODE_RECIPIENT_KEY returns the MPC node's age public key. It is safe to fetch over the network — think of it like an SSH authorized_keys entry. Only the node holds the corresponding private key and can decrypt what is encrypted to it.

The data flow for the age private key is: local RAM → local RAM → rawShardBytes.fill(0). Nothing sensitive is transmitted.


Putting It All Together

// ─────────────────────────────────────────────────────────
// DAY 0 — Ceremony (run on trusted server)
// ─────────────────────────────────────────────────────────

const { keyId } = await keyGenerationCeremony(); // from ceremony.md

// ─────────────────────────────────────────────────────────
// DAY 0 — Custodian setup (each custodian on their own machine)
// ─────────────────────────────────────────────────────────

// Each custodian: generateLocalAgeIdentity() → share public key → receive shard
// Each custodian: distributeAgeKey() → 3 guardian share files

// ─────────────────────────────────────────────────────────
// DAY 0 — Export + clear nodes (run on trusted server)
// ─────────────────────────────────────────────────────────

await exportAndClearAllNodes(keyId, [
{ name: 'custodian-a', agePublicKey: '<A-public-key>' },
{ name: 'custodian-b', agePublicKey: '<B-public-key>' },
{ name: 'custodian-c', agePublicKey: '<C-public-key>' },
], ['OFFICIAL_1', 'OFFICIAL_2', 'OFFICIAL_3']);
// Nodes are now EMPTY. Shards distributed to custodians via secure channel.

// ─────────────────────────────────────────────────────────
// ON DEMAND — Signing session (custodian's local machine)
// ─────────────────────────────────────────────────────────

// Import 2 shards (meets 2-of-3 threshold)
const keyId1 = await restoreShardToNode('./custodian-a-identity.json', './custodian-a-shard.json', '<A>', 'OFFICIAL_1');
const keyId2 = await restoreShardToNode('./custodian-b-identity.json', './custodian-b-shard.json', '<B>', 'OFFICIAL_2');

// ... signing operations using keyId1 and keyId2 ...

// Clear nodes after signing
await clearNodeAfterSigning(keyId1, 'OFFICIAL_1');
await clearNodeAfterSigning(keyId2, 'OFFICIAL_2');
// Nodes empty again.

// ─────────────────────────────────────────────────────────
// DAY 90 — Rotation (outgoing custodian's local machine)
// ─────────────────────────────────────────────────────────

await localRewrapShard(
'./custodian-a-shard.json',
'./custodian-a-identity.json',
'<A-old-passphrase>',
{ name: 'custodian-a-new', agePublicKey: '<new-A-public-key>' },
);
// Raw shard bytes: local RAM only — no network transmission of key material.
// Destroy old identity file after new custodian verifies decryption.

Full Lifecycle Diagram

KEY CEREMONY (Day 0)
─────────────────────────────────────────────────────────────

GENERATE_KEY_SHARE (threshold: 2, servers: all 3 nodes)

├── keyId ──────────────────────► DB + replica + printed copy
└── rootPublicKey ──────────────► Verified on-chain

age-keygen (local, per custodian device — NO API call)
│ AGE-SECRET-KEY-1... │ age1... (public)
│ │
envelopeEncrypt(privateKey, passphrase)│
└──► custodian-identity.json └──► shared with key generator
[stays on custodian device]

SSS split(agePrivateKey, total=5, threshold=3) → 5 shares
Each share → envelopeEncrypt(share, guardian_passphrase)
└──► guardian-{1..5}-age-share.json
[5 physically separate locations]

EXPORT_KEY_SHARE (deleteAfterExport: true) × 3
│ wrappedKeyShare — [node NOW EMPTY]
└──► custodian-{a,b,c}-shard.json
[geographically distributed cold storage]


SIGNING SESSION (on demand)
─────────────────────────────────────────────────────────────

envelopeDecrypt(identity, passphrase) → agePrivateKey [local RAM]

GET_NODE_RECIPIENT_KEY → node age public key [safe to fetch]

ageDecrypt(wrappedKeyShare, agePrivateKey) → rawShardBytes [local RAM]
ageEncrypt(rawShardBytes, nodePublicKey) → rewrappedForNode
rawShardBytes.fill(0)

IMPORT_KEY_SHARE → keyId on node [node is live]

... signing operations ...

EXPORT_KEY_SHARE (deleteAfterExport: true) → node EMPTY again


ROTATION (every 90 days or on custodian change)
─────────────────────────────────────────────────────────────

envelopeDecrypt(old identity, passphrase) → agePrivateKey [local RAM]

ageDecrypt(wrappedKeyShare, agePrivateKey) → rawShardBytes [local RAM]
ageEncrypt(rawShardBytes, newCustodian.publicKey) → rewrapped blob
rawShardBytes.fill(0)
└──► new-custodian-shard.json
[no API call — no network transmission of private key]

Continue to Disaster Recovery & Attack Prevention →