# Export Encrypted Key

Step 2 of key migration: receive the wallet's private key encrypted to an ephemeral X25519 key generated on the device.

## Overview

Call this after [Request Export Token](https://docs.aureahub.com/docs/wallets-export-token.md), while the wallet is `client_side_pending`. Send the raw token in the `X-Export-Token` header and the device's ephemeral X25519 public key in the body. The server decrypts the wallet key, encrypts it to that ephemeral key, and returns the ciphertext.

- **Tenant setting:** key export must be enabled for the tenant (`keyExportEnabled` in the tenant configuration); otherwise the call returns `400`.
- **Once per wallet:** after a successful export, every further call for the wallet returns `410`. The token is single-use and expires 10 minutes after it was issued.
- **No way back:** once the key has been exported, [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) returns `409`. Continue with [Migration Challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md) and [Confirm Client Custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md).
- The server keeps its own key material until custody is confirmed.

> ℹ️ Required header: `X-Export-Token: <exportToken>` — the 64-character hex value returned by `POST /v1/wallets/request-export-token`.

## Endpoint

### `POST /v1/wallets/export-key`

Authentication: bearer token required.

Returns the wallet private key encrypted with X25519 ECDH + HKDF-SHA256 + AES-256-GCM. Requires the X-Export-Token header. One export per wallet.

**Request body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `walletId` | string (uuid) | yes | Wallet in `client_side_pending` state. |
| `clientEphemeralPublicKey` | string | yes | Base64 of the raw 32-byte X25519 public key generated on the device for this export. |

**Responses**

`200` OK

```json
{
  "keyType": "evm",
  "encryptedPayload": "base64 AES-256-GCM ciphertext",
  "ephemeralPublicKey": "base64 raw 32-byte X25519 server public key",
  "iv": "base64 12-byte IV",
  "tag": "base64 16-byte GCM auth tag",
  "exportedAt": "2026-09-11T10:02:13.512Z"
}
```

`400` Export Disabled

```json
{
  "statusCode": 400,
  "error": "BadRequestError",
  "message": "Key export is not enabled for this tenant. Enable it in the admin settings."
}
```

## Encryption Scheme

1. The device generates an ephemeral X25519 key pair and sends the raw 32-byte public key, base64-encoded, as `clientEphemeralPublicKey`.
2. The server generates its own ephemeral X25519 key pair and computes the ECDH shared secret.
3. AES key = HKDF-SHA256(shared secret, salt: empty, info: `"aurea-key-export-v1"`, length: 32 bytes).
4. The plaintext is encrypted with AES-256-GCM under a random 12-byte IV. The response carries the ciphertext, the server's raw 32-byte ephemeral public key, the IV and the 16-byte auth tag, each base64-encoded.
5. The decrypted plaintext is UTF-8 JSON: `{"privateKey": "0x…"}` when `keyType` is `evm`, or `{"keypairHex": "…"}` (hex of the Solana secret key, 128 hex characters) when `keyType` is `solana`. `keyType` is `solana` for wallets on chain `solana` and `evm` for every other chain.

## Errors

Error responses

|  |  |  |
| --- | --- | --- |
| 400 | — | Missing `X-Export-Token` header or invalid body. |
| 400 | — | `Key export is not enabled for this tenant. Enable it in the admin settings.` |
| 400 | — | `clientEphemeralPublicKey must be a 32-byte X25519 public key (base64)` — the token is not consumed. |
| 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. |
| 409 | — | `Wallet is not in client_side_pending state. Call request-export-token first.` |
| 410 | — | `Key has already been exported to a device for this wallet` |
| 410 | — | `Export token not found or invalid`, `Export token has already been used` or `Export token has expired. Request a new one.` — while the wallet is `client_side_pending`, a new token can only be requested after cancelling the migration. |

## Implementation

Node.js, using the built-in `crypto` module:

```javascript
import crypto from 'node:crypto';

const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');

async function exportWalletKey(accessToken, walletId, exportToken) {
  // 1. Ephemeral X25519 key pair for this export only
  const eph = crypto.generateKeyPairSync('x25519');
  const clientEphemeralPublicKey = eph.publicKey
    .export({ type: 'spki', format: 'der' })
    .subarray(-32)                       // raw 32-byte key
    .toString('base64');

  // 2. Request the encrypted key
  const res = await fetch('https://api.aureahub.com/v1/wallets/export-key', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'X-Export-Token': exportToken
    },
    body: JSON.stringify({ walletId, clientEphemeralPublicKey })
  });
  if (!res.ok) throw new Error((await res.json()).message);
  const out = await res.json();

  // 3. Decrypt: ECDH -> HKDF-SHA256 -> AES-256-GCM
  const serverPublicKey = crypto.createPublicKey({
    key: Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(out.ephemeralPublicKey, 'base64')]),
    format: 'der',
    type: 'spki'
  });
  const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: serverPublicKey });
  const aesKey = Buffer.from(
    crypto.hkdfSync('sha256', shared, Buffer.alloc(0), Buffer.from('aurea-key-export-v1'), 32)
  );
  const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(out.iv, 'base64'));
  decipher.setAuthTag(Buffer.from(out.tag, 'base64'));
  const plaintext = Buffer.concat([
    decipher.update(Buffer.from(out.encryptedPayload, 'base64')),
    decipher.final()
  ]);

  // EVM: { privateKey: "0x..." }   Solana: { keypairHex: "..." }
  return { keyType: out.keyType, secret: JSON.parse(plaintext.toString('utf8')) };
}
```

---

Web version: https://docs.aureahub.com/#wallets-export-key
