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 |
Related
- Scopes and permissions — why export is gated apart
- Export endpoints — exact shapes
- Safe usage — handling what comes back