Skip to content

How to export key material safely

The sealed export path — what it returns, what it never returns, and how to open it.

Updated View as Markdown

Examples on this page use $API_URL. Set it once, with the /v1 prefix included — every route lives under it:

export API_URL=https://<your-api-host>/v1

Export is the highest-consequence operation in the service. A leaked signing token can sign what the key allows — a transaction its holder then broadcasts — and leaves a trace; leaked key material is permanent, offline, irrevocable control. The design reflects that.

Three gates, all required

An Unlimited-tier scoped API key

Export is not a workspace-token operation. The tenant mints an Unlimited scoped API key for the end user, bound to the account or wallet, and carrying the end user’s inquiry key:

curl -X POST "$API_URL/auth/api-key" \
  -H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
  -H "content-type: application/json" \
  -d '{"workspace":"demo","password":"…","account":"treasury","tier":"unlimited","inquiryKey":"x25519:<base64>"}'

A Basic key cannot export, and neither can a plain workspace token — both are refused with 403 scope_denied.

A live inquiry key

The end user generates an X25519 key pair in the browser and hands the tenant only the public inquiry key. It is time-boxed by validationDuration (default 4 hours): once it lapses, export locks again even though the key keeps working for everything else.

Encryption to the end user’s own key

The response never carries plaintext. It carries a blob sealed to the inquiry key, so it opens only for the holder of the matching private half — the end user, never the operator.

Export a mnemonic

HD accounts only. Returns 200:

curl -X POST "$API_URL/accounts/$ACCOUNT/export" \
  -H "Authorization: Bearer $SCOPED_KEY"
{
  "kind": "mnemonic",
  "account": "treasury",
  "sealed": {
    "alg": "x25519-hkdf-sha256-aes256gcm",
    "recipientFingerprint": "9f86d081884c7d65",
    "ephemeralPublicKey": "base64…",
    "nonce": "base64…",
    "ciphertext": "base64…",
    "tag": "base64…"
  }
}

A PK account has no mnemonic — asking returns 422 with unsupported_for_kind.

Export a private key

Per wallet, and the VM must be named:

curl -X POST "$API_URL/accounts/$ACCOUNT/wallets/1/export?vm=evm" \
  -H "Authorization: Bearer $SCOPED_KEY"

vm accepts exactly evm or svm; anything else — including omitting it — returns 400 with invalid_parameter. There is no default. The wallet id in the path must be a plain non-negative integer, and a non-canonical spelling (01, +1, 0x1) fails the same way. Asking for a VM the wallet has no address for returns 422 with unsupported_for_kind. The response echoes the selection back:

{
  "kind": "privateKey",
  "account": "treasury",
  "walletId": 1,
  "vm": "evm",
  "sealed": { }
}

Reading the sealed blob

Field Meaning
alg The sealing algorithm identifier. Currently always x25519-hkdf-sha256-aes256gcm
recipientFingerprint First 16 hex characters of SHA-256 over the recipient’s raw public key
ephemeralPublicKey Base64 raw X25519 public key of a single-use sender key
nonce Base64 AES-GCM nonce, 12 bytes
ciphertext Base64 sealed payload
tag Base64 GCM authentication tag, 16 bytes

Every export uses a fresh single-use sender key, so exporting the same secret twice returns two completely different blobs — different ciphertext, different ephemeralPublicKey — that decrypt to the same plaintext. Comparing blobs is not a way to tell whether two exports covered the same key.

Check recipientFingerprint against the public key you expect before attempting decryption — it tells you which key the blob was sealed to, and a mismatch means it was sealed for someone else.

Opening the blob requires the corresponding X25519 private key, which the service never sees and never holds. Decryption happens client-side — in the end user’s browser, where the inquiry key pair was generated. If you do not hold that private key, you cannot open what you exported — by design.

The full procedure — the key-derivation parameters and a working client — is in Decrypting a sealed blob. Read it before you export: the scheme binds the sender’s key as additional authenticated data, and a client that omits that fails in a way that looks like a corrupted blob.

Every attempt is audited

Every attempt that reaches the export handler is recorded with the tenant, workspace, account, target kind, and — for private keys — the wallet id and VM. “When did this key leave” has to be answerable after the fact, so a failed export is logged just as loudly as a successful one.

A token without the export scope (403 scope_denied) is recorded as well, with the outcome DENIED — so “who reached for key material and was refused” is answerable, not just “whose export ran”. That record names the tenant, workspace, and target kind and the scope required, but deliberately omits the account slug, wallet id, and vm: a scope check refuses before any of those have been validated, and recording unvalidated input is how an audit log becomes a place to inject text.

Two rejections still leave no record: an expired or missing token (401 session_expired) and a malformed slug, wallet id, or vm selector (400). If you are building detection on these records, that is the edge to know about.

Failures worth handling

Code Status What happened
scope_denied 403 Not an Unlimited key (no export scope), or no live inquiry key
unsupported_for_kind 422 Mnemonic from a PK account, or a VM the wallet lacks
invalid_parameter 400 vm was missing or not evm/svm, or the wallet id was not a plain integer
invalid_slug 400 The account slug in the path is not a lowercase slug
account_not_found 404 No such account or wallet
account_locked 423 The account is locked — its own password, an explicit lock, or a lapsed custody window. Unlock it
Navigation

Type to search…

↑↓ navigate↵ selectEsc close