Embedded Agent

Storage

Storage adapters that persist the EmbeddedAgent state across process restarts — in-memory, encrypted file, hardware-key-wrapped.

Storage

EmbeddedAgent.load() takes the encrypted blob export() produces and the passphrase you sealed it with. Where that blob lives between export() and load() is up to you — the SDK does not write to disk on its own. The storage-adapter helpers in @zafeguard/mpc-sdk are convenience wrappers for the most common backings: an in-memory map for tests, an encrypted-file directory for desktop apps, and a hardware-key-wrapped adapter for mobile / Secure Enclave / Keystore integrations.

All adapters implement the same four-method interface so swapping backends is one constructor change:

interface KeyStorage {
  put(keyId: string, blob: Buffer): Promise<void>;
  get(keyId: string): Promise<Buffer | null>;
  remove(keyId: string): Promise<void>;
  list(): Promise<string[]>;
}

The keyId is the label; the blob is whatever loaded.export(passphrase) returns.


InMemoryKeyStorage

A Map<keyId, Buffer> backing. Useful for tests, demos, and short-lived sessions where the share doesn't need to survive a process restart.

import {
  EmbeddedAgent,
  Curve,
  InMemoryKeyStorage,
  RecoveryKind,
} from '@zafeguard/mpc-sdk';

const storage = new InMemoryKeyStorage();
const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  keyId: 'test-signer',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});
await storage.put('test-signer', loaded.exportedBlob!);

// Later in the same process:
const blob = await storage.get('test-signer');
const restored = await signer.load({
  keyId: 'test-signer',
  blob: blob!,
  passphrase: 'demo-passphrase',
});

Does not survive process exit. Adapter is GC'd when the last reference drops.


EncryptedFileStorage

Writes each blob as a file in a directory you provide. The blob bytes go to disk as-is — they're already AES-256-GCM-sealed by export(), so the file itself is opaque ciphertext. Cross-platform via Node's fs/promises.

import {
  EmbeddedAgent,
  Curve,
  EncryptedFileStorage,
  RecoveryKind,
} from '@zafeguard/mpc-sdk';

const storage = new EncryptedFileStorage({ dir: './signer-state' });

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  keyId: 'production-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});
await storage.put('production-wallet', loaded.exportedBlob!);

// Later — fresh process, same directory + same passphrase:
const blob = await storage.get('production-wallet');
const restored = await signer.load({
  keyId: 'production-wallet',
  blob: blob!,
  passphrase: 'demo-passphrase',
});

Filenames are derived from the keyId; the directory is created if it doesn't exist. Removing a file (or the whole directory) is the only way to delete the share — there's no plaintext index to update.

The blob is sealed under your passphrase, but the file itself is unencrypted at the filesystem layer. On a multi-tenant host, restrict directory permissions (0700) so other local users can't read the ciphertext (even though they couldn't decrypt without the passphrase, denying access is an extra safety net).


EncryptedFileStorage

Two layers of encryption: the export()-produced blob goes inside a SECOND encryption layer keyed to a directory-wide passphrase. Useful for desktop apps that have one device passphrase covering many signers.

import {
  EncryptedFileStorage,
  EmbeddedAgent,
  Curve,
  RecoveryKind,
} from '@zafeguard/mpc-sdk';

const storage = new EncryptedFileStorage({
  dir: './signer-state',
  passphrase: 'device-unlock-passphrase',
});

// The signer-level passphrase and the storage-level passphrase are
// independent. You can rotate the storage passphrase by reading +
// re-writing every entry; rotating the signer passphrase requires
// LoadedKeyShare.recover().
const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  keyId: 'wallet-eth',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});
await storage.put('wallet-eth', loaded.exportedBlob!);

// Each file on disk is the storage-encrypted form of the
// signer-encrypted blob. Open requires BOTH the storage passphrase
// (to get the export blob out of the file) AND the signer passphrase
// (to decrypt the export blob into a LoadedKeyShare).
const blob = await storage.get('wallet-eth');
const restored = await signer.load({
  keyId: 'wallet-eth',
  blob: blob!,
  passphrase: 'demo-passphrase',
});

Each storage operation runs scrypt over the passphrase, so it's slower than EncryptedFileStorage. The default scrypt cost (N=32768) takes ~100ms per put/get on modern hardware; for batch workloads pass a lower scryptN (N=8192 for ~25ms) at the cost of brute-force resistance:

const storage = new EncryptedFileStorage({
  dir: './signer-state',
  passphrase: 'device-unlock-passphrase',
  scryptN: 8192,                       // cheaper scrypt for batch puts
});

HardwareWrappedStorage

Composes a KeyStorage (bytes-only persistence) with an OpaqueSealer (hardware-key-derived encryption) into a full KeyStorage. The underlying storage holds the wrapped ciphertext; the hardware key never leaves the secure element.

import {
  HardwareWrappedStorage,
  InMemoryKeyStorage,
  EmbeddedAgent,
  Curve,
  RecoveryKind,
} from '@zafeguard/mpc-sdk';

const storage = new HardwareWrappedStorage({
  sealer: mySecureEnclaveSealer,       // implements OpaqueSealer — see below
  backend: new InMemoryKeyStorage(),
  additionalAad: Buffer.from('licenseFingerprint:abc...', 'utf-8'),
});

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});

const loaded = await signer.create({
  keyId: 'mobile-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});
await storage.put('mobile-wallet', loaded.exportedBlob!);

The OpaqueSealer contract

Implement this interface against your platform's native crypto — Secure Enclave on iOS, Android Keystore / StrongBox, WebAuthn-derived secret, HSM, KMS. The SDK calls into the interface only and never reaches for the raw key bytes.

export interface OpaqueSealer {
  /**
   * Identifier for the underlying key material — surfaced in audit logs
   * and used by the integrator's UI to ask "which Secure Enclave key
   * did this share get sealed against?". Opaque to the SDK.
   */
  readonly id: string;

  /**
   * Seal `plaintext` under the hardware key. `aad` is optional but
   * recommended: bind a domain-separation context (license fingerprint,
   * keyId, epoch) so a sealed blob cannot be replayed against a
   * different logical key or rotation.
   */
  seal(plaintext: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;

  /**
   * Inverse of `seal`. The contract is "open with the same AAD that
   * was used at seal time" — mismatched AAD MUST fail to open.
   */
  open(ciphertext: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;
}

Example: iOS Secure Enclave (Expo / React Native)

Use P256Plugin — the SDK already ships an OpaqueSealer for P-256-based hardware. Don't reimplement the wrap; the SDK does the ECIES seal half (ephemeral ECDH + AES-256-GCM with a license-bound salt) and only asks you to wire the open half — a single callback that hands the wrapped bytes to your native bridge, which redoes the ECDH inside the Enclave and returns the plaintext.

The Secure Enclave holds a P-256 keypair; the SDK never sees the private half. The public half goes into the plugin so seal can run client-side without a round-trip; the open callback delegates the unseal into the Enclave (which triggers Face ID / Touch ID per the accessControl setting).

This example uses expo-secure-store for storage and the iOS Secure Enclave via react-native-secure-enclave-operations (one of several drop-in libraries — swap for your preferred bridge):

import {
  EmbeddedAgent,
  HardwareWrappedStorage,
  P256Plugin,
  Mpc,
  Curve,
  RecoveryKind,
  type KeyStorage,
} from '@zafeguard/mpc-sdk';
import * as SecureStore from 'expo-secure-store';
import SecureEnclave from 'react-native-secure-enclave-operations';

const SE_KEY_TAG = 'com.yourapp.mpc.share-wrap-key.v1';

// 1) Provision a Secure Enclave key once at app install. The private
//    key lives in the Enclave; only the public point is returned.
async function ensureEnclaveKey(): Promise<Uint8Array> {
  const exists = await SecureEnclave.keyExists(SE_KEY_TAG);
  if (!exists) {
    await SecureEnclave.generateKey({
      tag: SE_KEY_TAG,
      accessControl: 'biometryCurrentSet',  // Face ID / Touch ID on every use
    });
  }
  return SecureEnclave.getPublicKey(SE_KEY_TAG);
}

// 2) Implement KeyStorage backed by expo-secure-store. The blobs are
//    already SE-sealed by HardwareWrappedStorage — SecureStore adds
//    OS-level confidentiality on top of the cryptographic seal.
class ExpoSecureBlobStorage implements KeyStorage {
  async put(label: string, blob: Uint8Array): Promise<void> {
    await SecureStore.setItemAsync(label, Buffer.from(blob).toString('base64'), {
      keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
  }
  async get(label: string): Promise<Uint8Array | null> {
    const v = await SecureStore.getItemAsync(label);
    return v ? Buffer.from(v, 'base64') : null;
  }
  async remove(label: string): Promise<void> {
    await SecureStore.deleteItemAsync(label);
  }
  async list(): Promise<string[]> {
    // expo-secure-store doesn't expose a list API; track labels
    // separately in app state (e.g. a plain AsyncStorage index).
    throw new Error('list() not supported by expo-secure-store; track labels yourself');
  }
}

// 3) Wire P256Plugin to the SE bridge. Mpc.init returns the license
//    fingerprint that pins the wrap to this licensee.
const license = await Mpc.init(process.env.ZAFEGUARD_LICENSE_CERT!);
const sePublicKey = await ensureEnclaveKey();

const sealer = new P256Plugin({
  publicKeyBytes: sePublicKey,            // 33-byte compressed or 65-byte SEC1
  licenseFingerprint: license.fingerprint,
  unwrap: ({ ciphertext, licenseFingerprint }) =>
    SecureEnclave.ecdhUnwrap(SE_KEY_TAG, ciphertext, licenseFingerprint),
  // ↑ This is the only line that talks to native code. The bridge does
  //   ECDH inside the Enclave (triggering Face ID), derives the AES key,
  //   decrypts under the license-bound AAD, returns plaintext bytes.
});

// 4) Compose sealer + storage backend. HardwareWrappedStorage is
//    transparent — `EmbeddedAgent.create/load` only sees a KeyStorage.
const storage = new HardwareWrappedStorage({
  sealer,
  backend: new ExpoSecureBlobStorage(),
  // Optional extra AAD — bind to your app + epoch so a stolen blob
  // can't be replayed after wrap-key rotation.
  additionalAad: Buffer.from('app:com.yourapp/epoch:2026.1', 'utf-8'),
});

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: 'https://node-1.example', apiKey: '...' }],
  threshold: 2,
  curve: Curve.Secp256k1,
});
const loaded = await signer.create({
  keyId: 'mobile-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});

// On every put, iOS prompts for Face ID / Touch ID, the Enclave
// wraps the share's blob, expo-secure-store persists ciphertext.
await storage.put('mobile-wallet', loaded.exportedBlob!);

Attack-resistance properties:

  • Stolen device storage backup, no biometric. The blob is ciphertext sealed under an Enclave key that requires biometric / passcode to use. An attacker who lifts the SQLite / keychain file off the device can't open the blob without the user present.
  • Stolen device storage backup + jailbreak. The Enclave is hardware — even on a jailbroken device, the wrap key can't be extracted in software. The attacker can still PROMPT the user with the seal/open API, which is why accessControl: 'biometryCurrentSet' is the right setting (resets if the user adds a fingerprint, so a coerced enrollment doesn't quietly inherit access).
  • App reinstall. The Enclave key persists across app reinstalls; the user gets back their wallet by signing in again (the cloud agent has the other share). If the user actually destroys the device, cold recovery is the disaster path.

For Android, the equivalent pattern uses expo-secure-store (which delegates to Android Keystore / StrongBox) and the same P256Plugin — Android Keystore can be configured to hold a P-256 keypair with KeyGenParameterSpec.Builder + setUserAuthenticationRequired(true), and the unwrap callback bridges into the Keystore. Only the bridge implementation changes; the P256Plugin + HardwareWrappedStorage wiring is identical.

For browsers (WebAuthn-derived secret), swap P256Plugin for a custom OpaqueSealer that uses a PRF extension-based key derivation — the user's hardware authenticator becomes the wrapping key, with no plaintext private material ever in the browser process. See Implementing a custom OpaqueSealer below for the contract.

Implementing a custom OpaqueSealer

P256Plugin covers every hardware element that exposes a P-256 keypair (iOS Secure Enclave, most Android Keystore configurations, YubiKey FIDO2 with PRF). For non-P-256 hardware (an HSM that only exposes RSA, a custom KMS, WebAuthn PRF) implement the OpaqueSealer interface directly:

export interface OpaqueSealer {
  readonly id: string;
  seal(plaintext: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;
  open(ciphertext: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;
}

The contract is "open with the same AAD that was used at seal time" — mismatched AAD MUST fail to open. See the plugins page for a worked KMS example.

The KeyStorage contract

The bytes-only backend HardwareWrappedStorage persists wrapped blobs through. Implement it against whatever fits your platform — localStorage, AsyncStorage, IndexedDB, a SQLite blob column, an FS file, cloud KV.

export interface KeyStorage {
  put(label: string, blob: Uint8Array): Promise<void>;
  get(label: string): Promise<Uint8Array | null>;
  remove(label: string): Promise<void>;
  list(): Promise<string[]>;
}

All four methods are async because most browser / mobile storage APIs already are.

AAD binding

HardwareWrappedStorage uses the storage label (UTF-8) as AAD by default — same convention as EncryptedFileStorage — so renaming a blob under the backend fails the AEAD check on read, surfacing tampering as an error rather than corrupt output.

The optional additionalAad is mixed in alongside the label when you want extra domain separation. Bind your license fingerprint, the user id, or a rotation epoch here — any sealed blob is then bound to that context, and importing it under a different additionalAad fails open.


Picking an adapter

AdapterBackingBest for
InMemoryKeyStorageMap<string, Buffer>Tests, demos, short-lived sessions.
EncryptedFileStorageFiles in a directoryDesktop apps where the signer passphrase is the only secret.
EncryptedFileStorageFiles with a directory-wide passphrase layerMulti-signer desktop apps where one device passphrase unlocks many signers.
HardwareWrappedStorageAny KeyStorage + OpaqueSealerMobile / Secure-Enclave / HSM-backed integrations where the wrapping key never leaves the secure element.

The signer.load({ blob, passphrase }) interface doesn't care which adapter you used — it just needs the bytes + passphrase back. You can change adapters between writes; pull every blob through the old adapter, write them back through the new one.


Next

  • Recovery → — share rotation, sealed-bundle cold backup, off-line root-key reconstruction.
  • Presignature pool → — the pool rides inside the export() blob, so any storage adapter that survives a restart preserves the pool too.

On this page