> ## Documentation Index
> Fetch the complete documentation index at: https://developer.coincover.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Back up key material

> Encrypt your own recovery material client-side and store it with CoinCover, using an RSA key.

This guide covers the **encrypt-and-store** approach: you encrypt recovery material — seed phrases, key shares, or backup files — client-side with an `rsa4096` key, and store the ciphertext with CoinCover. We never see plaintext, and the private key needed to recover it never leaves our enclave.

If instead you want CoinCover to act as the backup key in a multi-sig wallet, see [Add CoinCover as a backup key](/institutional-recovery/add-a-backup-key).

## Step 1 — Authenticate

Authenticate with a Bearer token in the `Authorization` header — a JWT or a long-lived API key.

```bash theme={null}
export COINCOVER_API_KEY="<your-jwt-or-api-key>"
export COINCOVER_BASE_URL="https://service.uat-keys.coincover.com"
```

## Step 2 — Choose HOT or COLD

You choose the environment per key, and it's fixed for the life of the key.

| Environment | When to use it                                                                                                               |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `HOT`       | Online generation and storage. Best for high-frequency operations and low-latency access.                                    |
| `COLD`      | Offline generation and storage with enhanced isolation. Best for high-value wallets and the strictest security requirements. |

## Step 3 — Assign a key

Call `POST /v1/keys` with `key_type: "rsa4096"`. Only `user_identifier` and `key_type` are required; supply `organisation`/`package` only if you want to file the key against your own internal structure.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST "$COINCOVER_BASE_URL/v1/keys" \
    -H "Authorization: Bearer $COINCOVER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "user_identifier": "treasury-wallet-01",
      "key_type": "rsa4096",
      "key_environment": "COLD"
    }'
  ```

  ```typescript Node theme={null}
  const res = await fetch(`${baseUrl}/v1/keys`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      user_identifier: "treasury-wallet-01",
      key_type: "rsa4096",
      key_environment: "COLD",
    }),
  });
  const { key_id, public_key, signature } = await res.json();
  ```

  ```python Python theme={null}
  import requests

  res = requests.post(
      f"{base_url}/v1/keys",
      headers={
          "Authorization": f"Bearer {api_key}",
          "Content-Type": "application/json",
      },
      json={
          "user_identifier": "treasury-wallet-01",
          "key_type": "rsa4096",
          "key_environment": "COLD",
      },
  )
  key = res.json()
  key_id, public_key = key["key_id"], key["public_key"]
  ```
</CodeGroup>

Store the `key_id` against your wallet record. The response also carries a `signature` over the `public_key`, signed by the generating enclave — verify it with your CoinCover verification key before relying on the key. If verification fails, treat the response as untrusted and escalate.

### Optionally, bind business context into the signature

By default the signature covers only the `public_key`. Pass a `sign_with` array on the request to have the enclave also bind chosen context fields — alongside the `public_key`, which is always included — so a verified signature attests to *which* wallet, customer, or package the key was issued for, not just the key bytes.

Supported fields: `external_customer_id`, `external_package_id`, `pulled_by_id`, `pulled_by_type`, `user_id`, `key_id`, `key_fingerprint`. To bind `external_customer_id` or `external_package_id`, include the matching `organisation`/`package` on the request. Don't list `public_key` — it's always included implicitly, and passing it is rejected. Any field you list that can't be resolved returns a `400`. `key_fingerprint` is the SHA-256 (hex) of the public-key bytes; `pulled_by_id`/`pulled_by_type` are the caller identity CoinCover sets from your auth token, not request fields.

```bash theme={null}
curl -X POST "$COINCOVER_BASE_URL/v1/keys" \
  -H "Authorization: Bearer $COINCOVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_identifier": "treasury-wallet-01",
    "key_type": "rsa4096",
    "key_environment": "COLD",
    "organisation": { "customer_id": "acme-treasury" },
    "sign_with": ["external_customer_id", "user_id", "key_fingerprint"]
  }'
```

When `sign_with` is present, the response carries two extra fields alongside `signature`:

```json theme={null}
{
  "key_id": "b1f7c0de-2a4c-4f1e-9c3a-8d2e6b5a1f04",
  "public_key": "3082020a...",
  "signature": "MEUCIQD...base64...",
  "signed_payload": "{\"external_customer_id\":\"acme-treasury\",\"key_fingerprint\":\"9f86d081...\",\"public_key\":\"3082020a...\",\"user_id\":\"5e9b1c74-3a80-4d2e-b1f6-0c7a9e2d4415\"}",
  "signed_fields": ["external_customer_id", "user_id", "key_fingerprint"]
}
```

`signed_payload` is the exact [JCS-canonical](https://www.rfc-editor.org/rfc/rfc8785) JSON string the enclave signed: object keys are sorted and the `public_key` is always included alongside your requested fields. `signed_fields` echoes the fields you requested, in request order. <br /><br />Verify `signature` against your CoinCover verification key over those exact bytes — don't re-serialise the JSON — then confirm the bound values match the wallet you're provisioning. A failed signature or an unexpected value means the response is untrusted: stop and escalate.

<Note>
  `sign_with` is optional and additive. Omit it and the response is unchanged — the legacy signature covers the `public_key` alone, verified over the UTF-8 bytes of the hex `public_key` string (not its decoded bytes) — so existing integrations keep working.
</Note>

## Step 4 — Encrypt client-side

The public key comes back hex-encoded. Convert it to PEM, then encrypt with RSA-OAEP using SHA-256 as both the hash and MGF1 hash.

<Note>
  The checksum must be SHA-256 of the **plaintext**, not the ciphertext. It's optional on the standard endpoint, but supplying it lets CoinCover verify the recovered plaintext on retrieval — we recommend always sending it.
</Note>

<CodeGroup>
  ```typescript Node theme={null}
  import crypto from "node:crypto";

  function hexToPem(publicKeyHex: string): string {
    const der = Buffer.from(publicKeyHex, "hex");
    const b64 = der.toString("base64").match(/.{1,64}/g)!.join("\n");
    return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n`;
  }

  function encrypt(publicKeyHex: string, plaintext: Buffer) {
    const pem = hexToPem(publicKeyHex);
    const ciphertext = crypto.publicEncrypt(
      {
        key: pem,
        padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
        oaepHash: "sha256",
      },
      plaintext,
    );
    const checksum = crypto.createHash("sha256").update(plaintext).digest("hex");
    return { data: ciphertext.toString("base64"), checksum };
  }
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding

  def hex_to_pem(public_key_hex: str) -> bytes:
      der = bytes.fromhex(public_key_hex)
      b64 = base64.encodebytes(der).decode().strip()
      return f"-----BEGIN PUBLIC KEY-----\n{b64}\n-----END PUBLIC KEY-----\n".encode()

  def encrypt(public_key_hex: str, plaintext: bytes) -> dict:
      pem = hex_to_pem(public_key_hex)
      public_key = serialization.load_pem_public_key(pem)
      ciphertext = public_key.encrypt(
          plaintext,
          padding.OAEP(
              mgf=padding.MGF1(algorithm=hashes.SHA256()),
              algorithm=hashes.SHA256(),
              label=None,
          ),
      )
      return {
          "data": base64.b64encode(ciphertext).decode(),
          "checksum": hashlib.sha256(plaintext).hexdigest(),
      }
  ```
</CodeGroup>

RSA-OAEP has a maximum message size (around 446 bytes for a 4096-bit key with SHA-256). For anything larger, encrypt the payload with a fresh symmetric key and wrap that symmetric key with the public key — or use the file endpoint.

## Step 5 — Store encrypted data

Send the ciphertext, the plaintext checksum, and the public key to `POST /v1/secure/data`.

```bash theme={null}
curl -X POST "$COINCOVER_BASE_URL/v1/secure/data" \
  -H "Authorization: Bearer $COINCOVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": "04a1b2c3d4e5f6789...",
    "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "data": "eyJrZXkiOiJ2YWx1ZSJ9",
    "padding_type": "OAEP"
  }'
```

You get back a `backup_id`. Store it — it's the handle to find this exact backup again.

For binary backups — encrypted archives, wallet seed files — use `POST /v1/secure/file` instead. It's `multipart/form-data`, expects the file to already be encrypted on your side, and returns a `backup_id` the same way.

## Step 6 — Verify the key and backup

Confirm integrity with the self-service `verify` endpoints, right after assignment and storage and periodically thereafter as a drill.

```bash theme={null}
# Confirm the key's shards reconstruct and the pair is valid
curl "$COINCOVER_BASE_URL/v1/hot/keys/$KEY_ID/verify" \
  -H "Authorization: Bearer $COINCOVER_API_KEY"

# Confirm a stored backup is valid and recoverable
curl "$COINCOVER_BASE_URL/v1/hot/backups/$BACKUP_ID/verify" \
  -H "Authorization: Bearer $COINCOVER_API_KEY"
```

`verify-key` returns `is_key_valid`; `verify-backup` returns `is_backup_valid`. Treat a `false` from either as a hard failure and escalate before relying on the material.

## Step 7 — Test before you ship

Run through the going to production checklist before cutting production traffic to live keys. The most common issues:

* Hex-to-PEM conversion off by one byte (verify with a known-good public key first)
* OAEP hash and MGF1 hash mismatched (both should be SHA-256)
* Checksum computed over ciphertext rather than plaintext
* Sandbox base URL still set in production config
* `key_environment` defaulted to `HOT` where you intended `COLD`

The [testing page](/institutional-recovery/testing) has the full sandbox matrix.

## What's next

<CardGroup cols={2}>
  <Card title="Add CoinCover as a backup key" href="/business-recovery/add-a-backup-key">
    Use a secp256k1 key as the backup key in your multi-sig or MPC wallet.
  </Card>

  <Card title="API reference" href="/business-recovery/api-reference">
    Every endpoint, request, and response.
  </Card>
</CardGroup>
