> ## Documentation Index
> Fetch the complete documentation index at: https://sdk-guide.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Key Management

> How the wallet password, the three key shares and session unlocking work

## Overview

Trustodian is non-custodial: the backend never holds a complete private key. The account's recovery phrase is the one custody secret. It is split 2-of-3, and every wallet is re-derived from it when a key is needed, so no wallet has a key of its own to store. See [Recovery phrase](https://sdk-guide.mintlify.site/sdk-guide/recovery-phrase.md) for the seed itself; this page covers the shares, the wallet password and the session.

> [!WARNING]
>
> Everything on this page runs in your backend process. The SDK holds the API key, the end-user
> wallet password and reconstructed private keys in memory. Never ship it to a browser or a mobile
> client.

## The three shares

Every secret is split into three shares; any two of them reconstruct it.

| Share | Held by | At rest |
| :- | :- | :- |
| Device share | You, through the share store you implement | Raw share bytes. Encrypting your store is your responsibility |
| Server share | Trustodian API | Encrypted under a key derived from the user's wallet password |
| Recovery share | Trustodian crypto service | Encrypted under a second key derived from the same password |

Reconstruction always happens inside your process, never on the server:

* Signing uses the device share plus the server share.
* Recovery, rotation and password change use the server share plus the recovery share, because the device share is the piece that is missing or stale.

Every reconstruction is checked: the key that comes out must derive the address the wallet claims, or the operation fails instead of signing.

## The wallet password

The wallet password never leaves your process and is never sent to Trustodian, not even hashed. It is turned into key material through an exchange with the server in which the server learns nothing about the password itself (an oblivious PRF). Two independent keys come out of it:

| Key | Encrypts | Where it lives |
| :- | :- | :- |
| Session key | The server share | In memory for the life of the session, then wiped |
| Recovery key | The recovery share | Derived for one operation and wiped immediately. It is never stored |

That is why create, import, recover, rotate, change password and seed reads all take a `password` argument even when the session is already unlocked: they touch the recovery share, and the session cannot supply the recovery key.

### Strength rules

`setWalletPassword` and `changeWalletPassword` reject a new password that does not meet all of these.
Unlocking is deliberately exempt, so an account created under the old rules still opens.

| Rule | Value |
| :- | :- |
| Length | 12 to 256 characters |
| Character classes | uppercase, lowercase, digit and symbol, all four required |
| Alphabet | Latin letters and symbols only |

Validate in your own UI before you call the SDK, so the user sees the rule next to the field rather
than as a thrown error:

```typescript theme={null}
import { assertWalletPasswordStrength, MIN_WALLET_PASSWORD_LENGTH } from 'vaults-multichain-sdk';

assertWalletPasswordStrength(candidate); // throws with a message naming the rule that failed
```

When the session expires, the session key is wiped and any further crypto call throws `SESSION_LOCKED`. See [Authentication](https://sdk-guide.mintlify.site/sdk-guide/authentication.md) for unlocking, locking and the `keyTtlMs` session TTL.

## The share store

You must supply a `shareStore`, or the SDK constructor throws. It is where the device share for each wallet lives. This is the contract:

```typescript theme={null}
import type { IntegratorShareStore, DeviceShareRecord } from 'vaults-multichain-sdk';

interface DeviceShareRecord {
  userRef: string;
  walletId: string;
  address: string;
  chainType: EChainType | string;
  deviceShareHex: string;
  version: number;
}

interface IntegratorShareStore {
  save(record: DeviceShareRecord): Promise<void>;
  load(userRef: string, walletId: string): Promise<DeviceShareRecord | null>;
  findByAddress(
    userRef: string,
    chainType: string,
    address: string,
  ): Promise<DeviceShareRecord | null>;
  delete(userRef: string, walletId: string): Promise<void>;
}
```

Implementation rules:

* `save` overwrites. Rotation, recovery and password change all save a new record for the same `(userRef, walletId)`, so upsert on that pair.
* `version` is opaque to you. Store it and return it unchanged; the SDK uses it to tell a current device share from a stale one.
* `findByAddress` must normalize XRP. Addresses match exactly for every chain except XRP, which is lower-cased on both sides.
* `delete` must actually delete. The SDK removes a device share once it has established that the share is unusable, before rebuilding it.

> [!WARNING]
>
> `deviceShareHex` is key material. Combined with the server share it rebuilds a private key. Store
> it encrypted at rest, keep it out of logs, backups and error payloads, and scope every read to a
> single `userRef`.

`InMemoryShareStore` is for local development and tests only. It dies with the process, so every device share is lost and every wallet needs a password-based recovery on the next start.

```typescript theme={null}
import { InMemoryShareStore } from 'vaults-multichain-sdk';
```

## Wallets the user also uses elsewhere

A user who also uses the Trustodian web or mobile app has a separate set of encrypted shares for the same wallets, so a rotation on one side does not invalidate the other. The wallet password is shared across them; the device share is not.

The first time your integration touches a wallet that was created in another app, the SDK sets your side up automatically on the first operation. There is nothing to configure.

## Keeping a device share current

Before signing, the SDK checks that the device share it holds for the wallet is still the current one and, if it is not, rebuilds it from the server and recovery shares. That rebuild needs the wallet password.

There are two ways to work with this:

* Pass `walletPassword` on the crypto call and the SDK recovers silently before signing. This is the simple path.
* Or recover explicitly, when you would rather control when it happens:

```typescript theme={null}
import { isDeviceShareRecoveryError } from 'vaults-multichain-sdk';

try {
  await sdk.sendTransaction(input);
} catch (error) {
  if (isDeviceShareRecoveryError(error)) {
    await sdk.ensureWalletDeviceShare(userRef, {
      address: input.from,
      chainType: input.chain,
      password: walletPassword,
    });
    return sdk.sendTransaction(input);
  }
  throw error;
}
```

`isDeviceShareRecoveryError(error)` is `true` exactly when the failure is one the wallet password can fix: no device share on this host, a stale one, or one that no longer decrypts.

> [!NOTE]
>
> Seed wallets skip all of this. They have no device share; signing re-derives the key from the
> account seed. See [Recovery phrase](https://sdk-guide.mintlify.site/sdk-guide/recovery-phrase.md).

## Encryption key versions

The server key used in the password exchange is versioned. Every encrypted share records the
version it was wrapped under, and the SDK reads that version off each share and derives the
matching key. An account whose shares sit on several versions at once therefore keeps working.

Shares move onto the current version only when someone asks. Nothing rotates on unlock, because the
rotate endpoint allows three calls an hour and the SDK will not spend that budget on your behalf.

| Method | Arguments | Returns |
| :- | :- | :- |
| `getOprfKeyVersionStatus` | `(userRef)` | `{ activeOprfKeyVersion, upToDate, stale }` — read-only, no password |
| `rotateOprfKeyVersion` | `(userRef, { password, stepUp? })` | `{ rotated, skipped, seedPartitions, backupInvalidated? }` |

```typescript theme={null}
await sdk.rotateOprfKeyVersion(userRef, { password: walletPassword });
```

Call it once after a session is unlocked. It returns `{ rotated: 0, skipped: true }` when nothing is
stale, so it is safe to call every time. Cache the attempt for the life of the session — including a
failed one, since a failure leaves the account exactly as it was and retrying only burns the hourly
budget the user may need for their own recovery.

> [!WARNING]
>
> Ship SDK v3.0.0 before the platform raises the active key version, and make sure something in your
> integration actually calls `rotateOprfKeyVersion`. A rotation triggered from any client re-wraps
> this account's shares everywhere, including yours. An integration that never rotates stays on an
> older version indefinitely and will eventually fail to decrypt its own shares, burning the
> wallet-password lockout counter on every attempt.

`stepUp` is accepted but not required: the passkey step-up the web app performs is skipped for
API-key callers, so your key's own permissions are the gate. Rotating an account that has a recovery
phrase needs `wallet_management`.

## Share recovery API

Unlocking and locking a session, and reading password status, live in [Authentication](https://sdk-guide.mintlify.site/sdk-guide/authentication.md). The methods below rebuild device shares and rotate the password.

| Method | Arguments | Returns |
| :- | :- | :- |
| `recoverWallet` | `(userRef, { walletId, address, chainType, password, walletScope? })` | `{ version }` |
| `recoverWalletsBatch` | `(userRef, { password, wallets })` | `{ results, recoveredCount, failedCount, vaultKeys }` |
| `ensureWalletDeviceShare` | `(userRef, { walletId?, address, chainType, password, walletScope? })` | `{ hydrated, walletId }` |
| `ensureWalletReadyForCrypto` | `(userRef, { walletId?, address, chainType, walletPassword?, walletScope? })` | `{ walletId, recovered }` |
| `changeWalletPassword` | `(userRef, { currentPassword, newPassword, passwordHint?, stepUp? })` | `{ ok, walletsRotated, vaultActivationsRotated, cosignerKeysRotated, seedPartitionsRotated, seedPartitions, oprfKeyVersion, backupInvalidated? }` |

### Batch recovery

Use `recoverWalletsBatch` to rebuild many device shares at once, after restoring a host or after the user changed their password elsewhere. Large lists are chunked automatically, and a wallet that fails is reported in the results rather than aborting the batch. The password is checked once for the whole call, so a wrong one spends a single lockout attempt.

```typescript theme={null}
const { results } = await sdk.recoverWalletsBatch(userRef, {
  password: walletPassword,
  wallets: wallets.map((w) => ({
    walletId: w.id!,
    address: w.address,
    chainType: w.chain,
  })),
});

for (const result of results) {
  if (result.status === 'failed') {
    logger.warn({ address: result.walletAddress, error: result.error });
  }
}
```

The same call also sweeps the account's Bitcoin and Cardano vault seats and reports them separately
in `vaultKeys`, each entry `recovered`, `skipped` or `failed`. An employee who accepted a seat on
another device gets its local half back here, so pass the call an empty `wallets` list when the seats
are all you need to repair.

### Changing the wallet password

`changeWalletPassword` re-encrypts every share under keys derived from the new password: wallet shares, vault activation shares, provisioned vault seats, and, if the account has a recovery phrase, the seed shares. It is a full re-encryption pass, not a flag on the server. New device shares are written to your share store, and the session continues under the new password.

The result counts what moved, including `cosignerKeysRotated` for vault seats, and `oprfKeyVersion` reports the encryption key version everything now sits on.

The change proves the current password to the server, not only to your process: the request carries a proof derived from `currentPassword`, which the server compares with the one it stored when the password was last unlocked. Requires SDK v3.3.2 or newer.

| Failure | Error | What it means |
| :- | :- | :- |
| Wrong `currentPassword` | `WalletPasswordAttemptError` | Raised by the unlock step at the start of the change |
| The server refuses the proof | `WalletPasswordProofMismatchError` (`WALLET_PASSWORD_PROOF_MISMATCH`) | It counts as a failed wallet password attempt, so do not retry the same password in a loop |
| The server holds no proof yet | `WalletPasswordProofMissingError` (`WALLET_PASSWORD_PROOF_MISSING`) | Unlock the wallet password once, then change it. A password change always unlocks first, so an account the SDK has unlocked does not hit this |
| The stored encryption key version moved between the unlock and the request | `WalletPasswordProofVersionMismatchError` (`WALLET_PASSWORD_PROOF_VERSION_MISMATCH`) | Nothing was changed. Call `changeWalletPassword` again |

> [!WARNING]
>
> On an account enrolled in passkey or 2FA step-up that has a recovery phrase, pass `stepUp`.
> Without it the change fails rather than committing half-rotated. `backupInvalidated` in the result
> tells you a recovery-phrase backup recorded before the password change is no longer usable.

## Wallet creation

`createWallet` accepts an optional `mode`, but you should omit it. Every account reaching the SDK already has a recovery phrase, so the wallet is derived from the account seed: shareless, with nothing written to your share store. The private key exists only in your process, and what crosses the wire is an address.

```typescript theme={null}
const userRef = await sdk.getSdkUserId();
await sdk.unlockWalletSession(userRef, walletPassword);

const { wallets } = await sdk.createWallet({
  chain: EChainType.EVM,
  userRef,
  password: walletPassword,
});
```

`importWallet({ chain, privateKey, userRef, password })` takes an existing private key, checks that it derives the address it claims, and splits it the same way. It accepts the key formats the major wallets export. An imported key can never come from the account seed, so its device share is written to your share store, unlike a wallet created from the seed.

## Exporting private keys

> [!WARNING]
>
> As of SDK v3.0.0, exporting a wallet's private key is no longer available over the API-key/SDK
> transport. `sdk.exportWallet(...)` throws `PermissionDeniedError` immediately. Private-key export
> is available only in the first-party Trustodian web app.

## What is kept in memory, and for how long

The SDK wipes every secret buffer it owns as soon as it is finished with it: derived keys, decrypted shares and reconstructed private keys.

> [!NOTE]
>
> Two things cannot be wiped: JavaScript strings (the wallet password, a recovery phrase shown at
> establish time) and anything you copy out of the SDK. Keep those alive for as short a time as
> possible, and never place them in a log line, an error message, an APM span or a crash dump.

The reconstructed account seed is never kept between calls: each seed operation reconstructs,
derives and wipes, so it needs the wallet password every time. `seedEntropyCache` is deprecated and
ignored. See [Strict authentication](https://sdk-guide.mintlify.site/sdk-guide/strict-authentication.md#seed-wallets-need-the-password-on-every-signature).

## Vault activation shares

Creating and activating a multisig vault on Solana, Tron and XRP produces an activation secret, split the same 2-of-3 way. The SDK handles it when you pass an unlocked `userRef` to `createMultisig` / `activateMultisig`, and `changeWalletPassword` rotates it along with everything else. See [Multisig](https://sdk-guide.mintlify.site/sdk-guide/multisig.md) and [Organization vaults](https://sdk-guide.mintlify.site/sdk-guide/organization-vaults.md).

## Security responsibilities

| Party | Holds | Cannot do alone |
| :- | :- | :- |
| Trustodian API | The encrypted server share | Decrypt it. The key exists only in your process while unlocked |
| Trustodian crypto service | The encrypted recovery share | Decrypt it. That key is never stored anywhere |
| You (integrator) | The device share and the API key | Rebuild a key without a server share |
| End user | The wallet password, and for seed accounts the recovery phrase | |

No single party holds two usable shares. Compromise of the Trustodian side yields ciphertext with no key; compromise of your share store yields one share, which rebuilds nothing.

> [!WARNING]
>
> Never log wallet passwords, device shares, derived keys, reconstructed private keys, or recovery
> phrases. Redact them from error serializers too, since most logging stacks serialize error objects
> wholesale.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.