---
title: "How to export key material safely"
description: "The sealed export path — what it returns, what it never returns, and how to open it."
---

> Documentation Index
> Fetch the complete documentation index at: https://tee.hypetrade.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# How to export key material safely

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

1. **Step 1**

### An `export`-scoped token

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

```bash
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"]}'
```

2. **Step 2**

### 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.

3. **Step 3**

### 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:

```bash
curl -X POST "$API_URL/accounts/$ACCOUNT/export" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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:

```bash
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:

```json
{
  "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](/api/export/#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.

> **Treat the blob as key material**
>
> Sealed is not harmless. It is your key, encrypted. Do not put it in logs, issue
> trackers, chat, or anywhere with a longer retention than you would give the key
> itself.

## 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](/concepts/scopes/) — why export is gated apart
- [Export endpoints](/api/export/) — exact shapes
- [Safe usage](/safe-usage/) — handling what comes back

Source: https://tee.hypetrade.xyz/guides/export-key-material/index.mdx
