# Non-Custodial Key Lifecycle

How a wallet key ends up only on the user's device — by registering a client-side wallet, or by migrating a server-custodial wallet — and what the server keeps at each stage.

## Overview

A wallet's custody is recorded in its `keyManagementScheme`, returned by [Get Wallet](https://docs.aureahub.com/docs/wallets-get.md) and by the wallet creation and registration endpoints. There are two routes to a key held only on the device:

1. **Client-side from the start** — on `non_custodial` tenants the key is generated on the device and only the address is registered. The server never has the key.
2. **Migration** — an existing server-custodial wallet exports its key to the device, the device proves it holds the key, and the server removes its copy. The address does not change.

Neither route places key shares on the device or uses a threshold scheme. For 2-of-3 threshold wallets, see [MPC Wallets](https://docs.aureahub.com/docs/mpc-config.md).

## Custody Schemes

|  |  |  |
| --- | --- | --- |
| `aes_single` | Server-held key, AES-256-GCM encrypted in the database (legacy). | Custodial wallets stored without a key-vault share. |
| `sss_2of2_akv` | Server-held key split into two Shamir shares — one encrypted in the database, one in a key vault. Both are needed to rebuild the key. | Custodial wallets created while a key vault is configured. `aes_single` wallets are upgraded on first signing use when a key vault is available. |
| `client_side_pending` | Migration in progress. Server key material is intact; export and cancellation are possible. Transfers and swaps are already prepared for client-side signing. | `POST /v1/wallets/request-export-token` |
| `client_side` | Key only on the device; the server stores the address. | `POST /v1/wallets/client` or `POST /v1/wallets/confirm-client-custody` |
| `mpc_tss` | MPC threshold wallet with no server-held key. Cannot be exported or migrated. | MPC key-generation ceremony |

## Client-Side Wallets

On tenants with `walletMode: "non_custodial"`:

1. Generate the key on the device.
2. `GET /v1/wallets/client/challenge` with `chain` and `address` — a 32-byte challenge, valid for 10 minutes.
3. Sign the challenge bytes with the new key (see Signing Challenges below).
4. `POST /v1/wallets/client` with `{ chain, address, signedChallenge }` — creates a `client_side` wallet. Pass `chain: "evm"` to register the address on every supported EVM chain at once.

```typescript
import { Wallet, getBytes } from 'ethers';

const API = 'https://api.aureahub.com';
const bearer = { Authorization: `Bearer ${accessToken}` };

// secp256k1 key generated on the device — persist it in secure storage
const device = Wallet.createRandom();

const q = new URLSearchParams({ chain: 'gnosis', address: device.address });
const { challenge } = await fetch(`${API}/v1/wallets/client/challenge?${q}`, { headers: bearer })
  .then(r => r.json());

const wallet = await fetch(`${API}/v1/wallets/client`, {
  method: 'POST',
  headers: { ...bearer, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    chain: 'gnosis',
    address: device.address,
    signedChallenge: await device.signMessage(getBytes('0x' + challenge))
  })
}).then(r => r.json());   // wallet.keyManagementScheme === 'client_side'
```

Reference: [Non-Custodial Challenge](https://docs.aureahub.com/docs/wallets-client-challenge.md), [Register Client Wallet](https://docs.aureahub.com/docs/wallets-client-register.md).

## Signing Challenges

The registration challenge and the migration challenge are both 32 random bytes returned as 64 hex characters. Sign the **bytes**, not the hex text:

- **EVM** — EIP-191 `personal_sign`. The server recovers the signer and compares it with the wallet address.
- **Solana** — Ed25519 detached signature, sent hex-encoded. The server verifies it against the wallet's public key.

```typescript
import { getBytes } from 'ethers';
import nacl from 'tweetnacl';

// EVM
const evmSignature = await evmWallet.signMessage(getBytes('0x' + challenge));

// Solana (keypair from @solana/web3.js)
const solanaSignature = Buffer.from(
  nacl.sign.detached(Buffer.from(challenge, 'hex'), keypair.secretKey)
).toString('hex');
```

## Migrating to Client Custody

Moves a server-custodial wallet (`aes_single` or `sss_2of2_akv`) to `client_side` in four calls:

|  |  |  |
| --- | --- | --- |
| 1 | [POST /v1/wallets/request-export-token](https://docs.aureahub.com/docs/wallets-export-token.md) | Password check; one-time token valid 10 minutes; wallet becomes `client_side_pending`. |
| 2 | [POST /v1/wallets/export-key](https://docs.aureahub.com/docs/wallets-export-key.md) | `X-Export-Token` header; the key is returned encrypted to a device X25519 key. Once per wallet; key export must be enabled for the tenant. |
| 3 | [GET /v1/wallets/migration-challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md) | 32-byte challenge, valid 5 minutes. |
| 4 | [POST /v1/wallets/confirm-client-custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md) | Challenge signed with the exported key; server key material removed; wallet becomes `client_side`. |

> ⚠️ - Step 1 does not check whether key export is enabled for the tenant; step 2 does. If it is disabled, the wallet stays `client_side_pending` until you cancel.
> - From step 1, transfers and swaps for the wallet are prepared for client-side signing, but the device only has the key after step 2 — run the steps back to back.
> - An expired export token cannot be replaced while the wallet is `client_side_pending`: cancel the migration, then start again.
> - After step 2 the migration can no longer be cancelled.
> - Step 4 clears the key material in the database immediately; a background job that runs every 5 minutes then disables the key-vault share. There is no API call to reverse it. Store the decrypted key securely before step 4.

```typescript
import crypto from 'node:crypto';
import { Wallet, getBytes } from 'ethers';

const API = 'https://api.aureahub.com';
const bearer = { Authorization: `Bearer ${accessToken}` };
const json = { ...bearer, 'Content-Type': 'application/json' };

// 1. Export token — wallet becomes client_side_pending
const { exportToken } = await fetch(`${API}/v1/wallets/request-export-token`, {
  method: 'POST', headers: json, body: JSON.stringify({ walletId, password })
}).then(r => r.json());

// 2. Export to a fresh X25519 key and decrypt (X25519 ECDH -> HKDF-SHA256 -> AES-256-GCM)
const eph = crypto.generateKeyPairSync('x25519');
const clientEphemeralPublicKey = eph.publicKey
  .export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64');

const out = await fetch(`${API}/v1/wallets/export-key`, {
  method: 'POST',
  headers: { ...json, 'X-Export-Token': exportToken },
  body: JSON.stringify({ walletId, clientEphemeralPublicKey })
}).then(r => r.json());

const serverPublicKey = crypto.createPublicKey({
  key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), 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 { privateKey } = JSON.parse(Buffer.concat([
  decipher.update(Buffer.from(out.encryptedPayload, 'base64')), decipher.final()
]).toString('utf8'));                               // EVM payload: { privateKey: "0x..." }

const local = new Wallet(privateKey);                // store securely before step 4

// 3. Migration challenge (valid 5 minutes)
const { challenge } = await fetch(`${API}/v1/wallets/migration-challenge?walletId=${walletId}`, { headers: bearer })
  .then(r => r.json());

// 4. Prove custody — the server then removes its key material
const { migrated } = await fetch(`${API}/v1/wallets/confirm-client-custody`, {
  method: 'POST',
  headers: json,
  body: JSON.stringify({ walletId, signedChallenge: await local.signMessage(getBytes('0x' + challenge)) })
}).then(r => r.json());
```

## Cancelling a Migration

[Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) (`POST /v1/wallets/cancel-migration` with `walletId` and `password`) works only while the wallet is `client_side_pending` and before a successful export. It invalidates unused export tokens and restores `sss_2of2_akv` (or `aes_single` when the wallet has no database key share). Because server key material is untouched until step 4, the wallet is server-custodial again straight away. After an export the call returns `409`.

```typescript
await fetch(`${API}/v1/wallets/cancel-migration`, {
  method: 'POST',
  headers: json,
  body: JSON.stringify({ walletId, password })
}); // { cancelled: true }
```

## Device Key

[Register Device Key](https://docs.aureahub.com/docs/wallets-register-device.md) stores one device public key on a wallet, authorised by a migration-challenge signature made with the wallet's own key. It does not create or store key shares, and it is not used to encrypt the exported key. Because the migration challenge is refused for `client_side` wallets, the call can only be completed before custody is confirmed; it consumes the challenge, so fetch a new one before step 4.

## Server-Side Key Storage

Shamir's Secret Sharing is used only for **server-custodial** keys. The private key is split into two shares; neither share alone reveals anything and both are required to reconstruct it. The first share is encrypted and stored in the database, the second is stored in a key vault. The server rebuilds the key in memory when it needs it — to sign, or to encrypt it for the device during a migration. An Aurea administrator can rotate the shares.

Wallets registered as `client_side` never have server key material. For a migrated wallet, step 4 clears the database key material immediately and the key-vault share is disabled by the background cleanup job shortly afterwards.

---

Web version: https://docs.aureahub.com/#guide-non-custodial-keys
