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

Export is the highest-consequence operation in the service. A leaked signing token moves funds within policy and leaves a trace; leaked key material is permanent, offline, irrevocable control. The design reflects that.

Three gates, all required

An export-scoped token

Request it by name at mint time — no default grant includes it:

curl -X POST "$API_URL/auth/token" \
  -H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
  -H "content-type: application/json" \
  -d '{"workspace":"demo","password":"…","scopes":["read","export"]}'

An operator-registered public key

Your operator must have set an exportPublicKey (X25519) for the tenant. If they have not, the mint itself fails with 403 and export_disabled — you cannot even hold the token.

Encryption to that key

The response never carries plaintext. It carries a blob sealed to the registered public key, so a stolen token yields something the thief cannot open.

Export a mnemonic

HD accounts only:

curl -X POST "$API_URL/accounts/$ACCOUNT/export" \
  -H "Authorization: Bearer $TOKEN"
{
  "kind": "mnemonic",
  "account": "treasury-a1b2",
  "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 $TOKEN"

vm accepts exactly evm or svm; anything else returns 400 with invalid_parameter. 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-a1b2",
  "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

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 on the operator side, offline. 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
export_disabled 403 No exportPublicKey registered for the tenant
scope_denied 403 Token lacks export
unsupported_for_kind 422 Mnemonic from a PK account, or a VM the wallet lacks
invalid_parameter 400 vm was not evm or svm
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