Key export
export_private_key removes a key from Kryard's custody — but the plaintext private key is never returned. You supply a recipient public key; the signer decrypts the key inside its boundary, HPKE-seals it to your recipient key, and returns only the sealed bundle. Only the holder of the matching recipient private key can open it.
Security-sensitive and gated
Export is disabled by default in the signer (ALLOW_EXPORT) — a disabled request fails with SIGNER_ERROR (403). Once exported, the key's exported flag is set as audit metadata. Export still goes through policy + the signer's evaluatedInputHash re-check.
The flow
- Generate a recipient keypair on the caller side — a P-256 keypair. Keep the private key local; it never leaves you.
- Submit
export_private_keywith the recipient public key astargetPublicKey. - The signer envelope-decrypts the key, HPKE-seals it to
targetPublicKey, zeroizes the plaintext, and returns the export bundle. - HPKE-open the bundle with your recipient private key to recover the raw key.
Request
POST /public/v1/submit/export_private_key
| Parameter | Type | Notes |
|---|---|---|
privateKeyId or signWith | string | The key to export (id, or address via signWith). Required. |
targetPublicKey | string | Recipient P-256 public key, hex — uncompressed (65-byte 0x04-prefixed) or compressed (33-byte 0x02/0x03). Required. |
Example — SDK
All-in-one — the SDK generates an ephemeral P-256 recipient, submits the export, opens the bundle, and zeroizes the recipient, returning the raw key:
const { privateKey } = await client.exportPrivateKey({
signWith: "b3f1…", // private-key id or address
});
// privateKey: raw key hex (no 0x) — handle securely and discard when done.Bring your own recipient — keep the recipient private key under your control and open the bundle yourself (see Decrypting the bundle):
const { privateKeyId, exportBundle } = await client.exportPrivateKeyBundle({
signWith: "b3f1…",
targetPublicKey: recipientPublicKeyHex, // your P-256 recipient public key, SEC1 hex
});Example — raw HTTP
{
"type": "ACTIVITY_TYPE_EXPORT_PRIVATE_KEY",
"timestampMs": "1748956800000",
"organizationId": "org-…",
"parameters": {
"privateKeyId": "b3f1…",
"targetPublicKey": "04a1b2…"
}
}Response — the export bundle
result.exportPrivateKeyResult.exportBundle is an RFC-9180 HPKE bundle. It contains no plaintext — only the HPKE encapsulated key and ciphertext:
{
"activity": {
"status": "ACTIVITY_STATUS_COMPLETED",
"result": {
"exportPrivateKeyResult": {
"privateKeyId": "b3f1…",
"exportBundle": {
"encappedPublic": "BASE64(enc)",
"ciphertext": "BASE64(ct)",
"info": "kryard-export-v1",
"kemKdfAead": "P256_HKDF_SHA256/HKDF_SHA256/AES256GCM"
}
}
}
}
}| Field | Meaning |
|---|---|
encappedPublic | base64 of the HPKE encapsulated key (enc). |
ciphertext | base64 of the sealed private key (ct). |
info | the HPKE info label, kryard-export-v1 — binds the bundle to the export-protocol version. |
kemKdfAead | the HPKE suite identifier (below). |
HPKE suite
The bundle is sealed under a fixed RFC-9180 suite (Turnkey's suite):
| Component | Value |
|---|---|
| KEM | KEM_P256_HKDF_SHA256 |
| KDF | KDF_HKDF_SHA256 |
| AEAD | AEAD_AES256GCM |
info | kryard-export-v1 |
aad (additional authenticated data) | the privateKeyId |
The aad binds the ciphertext to the exact key id, so a bundle cannot be silently retargeted to a different key.
Clean RFC-9180, not Turnkey's wrapper
This is a standard RFC-9180 HPKE bundle using Turnkey's cryptographic suite — open it with Kryard's own decryptExportBundle (below) or any RFC-9180 HPKE library. It is not byte-compatible with Turnkey's client wrapper, which expects their exact enclave-signed format.
Decrypting the bundle
The SDK ships the matching opener. generateRecipientKeyPair gives you a P-256 recipient (use its publicKeyHex as targetPublicKey); decryptExportBundle opens the bundle with the suite, info, and aad = privateKeyId already wired:
import { generateRecipientKeyPair, decryptExportBundle } from "@kryard/sdk";
const recipient = await generateRecipientKeyPair();
const { privateKeyId, exportBundle } = await client.exportPrivateKeyBundle({
signWith: "b3f1…",
targetPublicKey: recipient.publicKeyHex,
});
const keyBytes = await decryptExportBundle(exportBundle, recipient.privateKey, privateKeyId);
// keyBytes: the raw private key — handle securely, then keyBytes.fill(0).TIP
client.exportPrivateKey({ signWith }) does exactly this for you — generate → export → decrypt → zeroize — and returns { privateKey }. Use the manual flow above only when the recipient key must stay under your control.
Under the hood it's a plain RFC-9180 open, so it's equivalent to doing it by hand with any HPKE library (same suite, info = "kryard-export-v1", aad = privateKeyId):
// Equivalent manual open with @hpke/core.
import { CipherSuite, Aes256Gcm, HkdfSha256, DhkemP256HkdfSha256 } from "@hpke/core";
const suite = new CipherSuite({
kem: new DhkemP256HkdfSha256(),
kdf: new HkdfSha256(),
aead: new Aes256Gcm(),
});
const recipientCtx = await suite.createRecipientContext({
recipientKey: recipientPrivateKey, // your P-256 private key
enc: base64Decode(exportBundle.encappedPublic),
info: new TextEncoder().encode(exportBundle.info), // "kryard-export-v1"
});
const privateKeyBytes = await recipientCtx.open(
base64Decode(exportBundle.ciphertext),
new TextEncoder().encode(privateKeyId), // aad
);
// privateKeyBytes is the raw key — handle it securely and zeroize when done.