Custodian Setup & Shamir Secret Sharing
Each custodian is responsible for protecting one MPC shard. This page covers two layers of protection for the custodian's age identity:
- Local age key generation — the private key never leaves the custodian's device
- Shamir Secret Sharing — splits the age private key across multiple guardians so loss of one guardian doesn't mean loss of access
Section 1 — Generating Age Keys Locally​
GENERATE_AGE_ENCRYPTION is convenient but sends the age private key through the White Rabbit API. For institutional custody, each custodian must generate their own age key locally — the private key must never leave their device during generation.
Each custodian runs this once, on their own machine, air-gapped from the internet where possible.
Option A — age CLI (recommended for non-developers)​
# Install age (https://github.com/FiloSottile/age)
brew install age # macOS
apt install age # Debian/Ubuntu
choco install age.portable # Windows
# Generate a key pair — output stays on this device only
age-keygen -o custodian-a-identity.txt
# The file contains both keys. To print only the public key (safe to share):
age-keygen -y custodian-a-identity.txt
# → age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
The custodian shares only the public key (age1...) with the key generator. The identity file stays on their machine.
Option B — Node.js​
npm install age-encryption
import * as age from 'age-encryption';
import * as crypto from 'crypto';
import * as fs from 'fs';
// ─────────────────────────────────────────────────────────────────────────────
// Envelope encryption — AES-256-GCM + PBKDF2.
// Protects the age private key with a custodian-controlled passphrase.
// These helpers are reused throughout this guide.
// ─────────────────────────────────────────────────────────────────────────────
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 recommendation
const PBKDF2_KEYLEN = 32;
const PBKDF2_DIGEST = 'sha512';
export function envelopeEncrypt(plaintext: string, passphrase: string): string {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);
const key = crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([salt, iv, tag, ciphertext]).toString('base64');
}
export function envelopeDecrypt(blob: string, passphrase: string): string {
const buf = Buffer.from(blob, 'base64');
const salt = buf.subarray(0, 32);
const iv = buf.subarray(32, 44);
const tag = buf.subarray(44, 60);
const ciphertext = buf.subarray(60);
const key = crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
// ─────────────────────────────────────────────────────────────────────────────
// Run this on the custodian's local machine.
// Output never touches the network — only the public key is shared.
// ─────────────────────────────────────────────────────────────────────────────
export function generateLocalAgeIdentity(
custodianName: string,
passphrase: string, // custodian's own passphrase — never stored by anyone else
): { agePublicKey: string; identityFilePath: string } {
// Generate X25519 key pair entirely in-process — no API call
const identity = new age.X25519Identity();
const privateKey = identity.toString(); // "AGE-SECRET-KEY-1..."
const publicKey = identity.recipient().toString(); // "age1..."
// Protect the private key with the custodian's passphrase before writing to disk
const encryptedPrivateKey = envelopeEncrypt(privateKey, passphrase);
const record = {
custodian: custodianName,
agePublicKey: publicKey, // safe to share — send to key generator
encryptedPrivateKey, // AES-256-GCM protected — stays on this device
createdAt: new Date().toISOString(),
};
const filePath = `./${custodianName}-identity.json`;
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), { mode: 0o600 });
console.log(`[${custodianName}] Public key (share this):`, publicKey);
console.log(`[${custodianName}] Identity file (keep private):`, filePath);
return { agePublicKey: publicKey, identityFilePath: filePath };
}
When a custodian calls GENERATE_AGE_ENCRYPTION through the SDK, the private key passes through White Rabbit's API servers in server memory and TLS session state before reaching the custodian.
Generating locally means the private key is created inside the custodian's own process, in their own RAM. It never touches a network socket — equivalent to generating a PGP key yourself versus asking a web service to do it.
PBKDF2 is a password-based key derivation function. 600,000 SHA-512 iterations makes deriving the AES key take ~300ms — imperceptible for a user, but 600,000× slower for an attacker brute-forcing passphrases. Always use a strong passphrase (≥ 24 characters, diceware recommended).
Section 2 — Protecting Age Keys with Shamir Secret Sharing​
A single passphrase protecting an age key is a single point of failure. Shamir Secret Sharing (SSS) splits the age private key into N shares where any M can reconstruct it, but fewer than M reveal nothing.
npm install shamir-secret-sharing
import { split, combine } from 'shamir-secret-sharing';
// ─────────────────────────────────────────────────────────────────────────────
// Split the age private key into N shares, requiring M to reconstruct.
// Typical institutional setup: 5 shares, 3-of-5 threshold.
// ─────────────────────────────────────────────────────────────────────────────
async function splitAgeKey(
agePrivateKey: string,
totalShares: number,
threshold: number,
): Promise<Uint8Array[]> {
const keyBytes = new TextEncoder().encode(agePrivateKey);
return split(keyBytes, totalShares, threshold);
}
async function combineShares(shares: Uint8Array[]): Promise<string> {
const combined = await combine(shares);
return new TextDecoder().decode(combined); // reconstructed "AGE-SECRET-KEY-1..."
}
// ─────────────────────────────────────────────────────────────────────────────
// Each SSS share gets its own passphrase — one per guardian.
// Guardian 1's share → encrypted with Guardian 1's passphrase only.
// Guardian 2's share → encrypted with Guardian 2's passphrase only.
// ──────────────────────────────────────────────────────────────────────────── ─
function encryptShare(share: Uint8Array, guardianPassphrase: string): string {
return envelopeEncrypt(Buffer.from(share).toString('base64'), guardianPassphrase);
}
function decryptShare(encryptedShare: string, guardianPassphrase: string): Uint8Array {
const base64 = envelopeDecrypt(encryptedShare, guardianPassphrase);
return new Uint8Array(Buffer.from(base64, 'base64'));
}
// ─────────────────────────────────────────────────────────────────────────────
// Distribute — each guardian receives and stores only their own share.
// ─────────────────────────────────────────────────────────────────────────────
export async function distributeAgeKey(
agePrivateKey: string,
guardians: Array<{ name: string; passphrase: string }>,
threshold: number,
): Promise<void> {
const rawShares = await splitAgeKey(agePrivateKey, guardians.length, threshold);
for (let i = 0; i < guardians.length; i++) {
const { name, passphrase } = guardians[i];
const encryptedShare = encryptShare(rawShares[i], passphrase);
const shareFile = `./${name}-age-share.json`;
fs.writeFileSync(shareFile, JSON.stringify({
guardian: name,
shareIndex: i + 1,
totalShares: guardians.length,
threshold,
encryptedShare, // AES-256-GCM — only this guardian's passphrase can decrypt
createdAt: new Date().toISOString(),
}, null, 2), { mode: 0o600 });
console.log(`[${name}] Share ${i + 1}/${guardians.length} → ${shareFile}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Reconstruct — requires M guardians to provide their share + passphrase.
// Used during emergency recovery or custodian rotation.
// ─────────────────────────────────────────────────────────────────────────────
export async function reconstructAgeKey(
contributions: Array<{ shareFile: string; guardianPassphrase: string }>,
): Promise<string> {
const rawShares = contributions.map(({ shareFile, guardianPassphrase }) => {
const record = JSON.parse(fs.readFileSync(shareFile, 'utf8'));
return decryptShare(record.encryptedShare, guardianPassphrase);
});
return combineShares(rawShares);
}
SSS is a mathematical technique where a secret is split into N pieces such that any M reconstruct it exactly, but M-1 pieces reveal absolutely zero information about the secret. This is not like splitting a password in half — a partial share set is cryptographically indistinguishable from random noise.
Typical institutional setup: 3-of-5
- 5 guardians: CFO, CTO, General Counsel, Board Member A, Board Member B
- Any 3 can convene to reconstruct the age key
- Up to 2 guardians can be lost or compromised without breaking access
An SSS share is itself a byte string. If you store shares in plaintext, an attacker who breaches two guardian devices (for a 3-of-5 setup) is only one breach away from the key. Adding a per-guardian passphrase means an attacker needs both the file and that guardian's passphrase — turning a digital breach into a combined digital + social engineering attack.
Custodian day-0 workflow​
Run this sequence on each custodian's own machine after the key generation ceremony:
// Step 1 — Generate age identity locally
const { agePublicKey } = generateLocalAgeIdentity('custodian-a', '<A-passphrase>');
// → custodian-a-identity.json (stays on this machine)
// → Send agePublicKey to key generator to receive the shard
// Step 2 — After receiving the shard file, split the age key with SSS
const agePrivateKey = envelopeDecrypt(
JSON.parse(fs.readFileSync('./custodian-a-identity.json', 'utf8')).encryptedPrivateKey,
'<A-passphrase>',
);
await distributeAgeKey(agePrivateKey, [
{ name: 'guardian-a1', passphrase: '<G1-passphrase>' },
{ name: 'guardian-a2', passphrase: '<G2-passphrase>' },
{ name: 'guardian-a3', passphrase: '<G3-passphrase>' },
], 2); // 2-of-3 SSS threshold
// Zero the in-memory age key immediately — it is now protected by 3 guardians
// Distribute guardian share files to 3 physically separate locations
Continue to Export, Rotation & Restore →