---
title: "Export a mnemonic or private key, sealed"
description: "Sealed mnemonic and private-key export."
---

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

# Export a mnemonic or private key, sealed

**Auth: workspace token. Scope: `export` on both routes.**

Neither route returns plaintext key material. Both return a blob encrypted to
an X25519 public key your operator registered by hand.

Requesting the `export` scope without a registered key fails at **mint time**
with `403` and `export_disabled` — you cannot hold a token these routes accept.

## `POST /v1/accounts/{slug}/export`

Export an account's mnemonic. HD accounts only. Returns `200`.

```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 — returns `422` with `unsupported_for_kind`.

## `POST /v1/accounts/{slug}/wallets/{id}/export`

Export one wallet's private key. Returns `200`.

### Query parameters

| Parameter | Required | Values |
|---|---|---|
| `vm` | yes | `evm` or `svm` |

Any other value returns `400` with `invalid_parameter`. 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": { }
}
```

## The sealed blob

| Field | Meaning |
|---|---|
| `alg` | 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. Decrypts to UTF-8 |
| `tag` | Base64 GCM authentication tag, 16 bytes. Kept separate, not appended to `ciphertext` |

Check `recipientFingerprint` against the key you expect before attempting
decryption — a mismatch means the blob was sealed for someone else.

Opening it requires the corresponding X25519 private key, which the service
never sees and never holds.

> **Sealed is not harmless**
>
> It is your key, encrypted. Keep it out of logs, tickets, and chat.

## Decrypting a sealed blob

The scheme is ephemeral-static ECDH over X25519, HKDF-SHA256 to derive an
AES-256-GCM key, with the sender's public key bound as additional
authenticated data. Every parameter you need is below — the blob carries no
others, and none are negotiable.

| Step | Parameter | Value |
|---|---|---|
| 1 | Sender public key | `ephemeralPublicKey`, base64-decoded — 32 raw bytes |
| 2 | Shared secret | X25519(your private key, sender public key) |
| 3 | HKDF hash | SHA-256 |
| 3 | HKDF salt | sender public key ‖ your public key — both raw, 64 bytes, **in that order** |
| 3 | HKDF info | the ASCII string `tee-docker:export:x25519-hkdf-sha256-aes256gcm` |
| 3 | HKDF output | 32 bytes — the AES key |
| 4 | Cipher | AES-256-GCM |
| 4 | Nonce | `nonce`, base64-decoded — 12 bytes |
| 4 | AAD | the sender public key again — the same 32 raw bytes from step 1 |
| 4 | Auth tag | `tag`, base64-decoded — 16 bytes |
| 5 | Plaintext | UTF-8 |

All base64 in the blob is standard, with padding — not base64url.

> **The AAD is not optional**
>
> Skip `setAAD` and GCM fails at the final step with an authentication error
> that looks exactly like a corrupted blob, a wrong key, or tampering. If your
> decryption fails and `recipientFingerprint` matches the key you expect, a
> missing or wrong AAD is the first thing to check.

```js
import { createDecipheriv, createPublicKey, diffieHellman, hkdfSync } from "node:crypto";

// DER prefix for a raw X25519 public key, so the 32 bytes can be imported.
const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");

export function unseal(sealed, recipientPrivateKey) {
  const senderRaw = Buffer.from(sealed.ephemeralPublicKey, "base64");
  const senderKey = createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX, senderRaw]),
format: "der",
type: "spki",
  });

  // Your own public key, raw — the second half of the HKDF salt.
  const recipientRaw = createPublicKey(recipientPrivateKey)
.export({ format: "der", type: "spki" })
.subarray(-32);

  const shared = diffieHellman({ privateKey: recipientPrivateKey, publicKey: senderKey });

  // Bind the info string to the constant, not to `sealed.alg` — the blob is
  // input, and reading a key-derivation parameter out of it lets a sender
  // choose your key schedule.
  const aesKey = Buffer.from(
hkdfSync(
  "sha256",
  shared,
  Buffer.concat([senderRaw, recipientRaw]),
  "tee-docker:export:x25519-hkdf-sha256-aes256gcm",
  32,
),
  );

  const decipher = createDecipheriv("aes-256-gcm", aesKey, Buffer.from(sealed.nonce, "base64"));
  decipher.setAAD(senderRaw);
  decipher.setAuthTag(Buffer.from(sealed.tag, "base64"));

  return Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, "base64")),
decipher.final(),
  ]).toString("utf8");
}
```

Check `alg` before decrypting and refuse anything you do not recognise. A new
algorithm identifier means a new procedure, not a variation on this one.

## Auditing

Every attempt that reaches the handler is logged — `ATTEMPT`, then `SUCCESS`
or `FAILURE` — with the tenant, workspace, account, and target kind, plus the
wallet id and VM for private-key exports. Failures are logged as loudly as
successes.

A refusal for a missing `export` scope is recorded too, as a fourth outcome:
`DENIED`. It carries the tenant, workspace, and target kind, plus the scope the
route required — but **not** the account slug, wallet id, or `vm`. At the moment
a scope check refuses, those are still unvalidated caller input, so they are
deliberately kept out of the record. A denial tells you someone reached for key
export and was turned away; it does not tell you which account they named.

The other pre-handler rejections leave no record at all: `session_expired` is
refused by the token guard, and `invalid_slug` and `invalid_parameter` are
refused during argument parsing.

## Errors

| Status | Code |
|---|---|
| 400 | `invalid_slug`, `invalid_parameter` |
| 401 | `session_expired` |
| 403 | `scope_denied`, `export_disabled` |
| 404 | `account_not_found` |
| 422 | `unsupported_for_kind` |
| 423 | `account_locked` |

## Related

- [Export key material](/guides/export-key-material/) — the task walkthrough
- [Scopes and permissions](/concepts/scopes/) — why export is gated apart

Source: https://tee.hypetrade.xyz/api/export/index.mdx
