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.
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)
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.`);
}
}
deleteAfterExport: trueThis 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.
- 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.
REWRAPPING_KEY_SHARE for institutional custodyREWRAPPING_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:
| Method | Where age key exists |
|---|---|
REWRAPPING_KEY_SHARE | Local 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;
}
- 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.`);
}
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 →