---
title: "Encryption keys"
description: "Encrypt each record with its own data key, wrapped by a regional KMS key, with per-user keys so one call erases a person everywhere."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "keys"
canonical: "https://mainsdk.dev/docs/interfaces/keys"
updated: "2026-10-28"
---

# Encryption keys

> Encrypt each record with its own data key, wrapped by a regional KMS key, with per-user keys so one call erases a person everywhere.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/keys · Updated: 2026-10-28

`keys` encrypts each record with its own data key, wrapped by a regional KMS key, so one call erases a person everywhere, backups included.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/keys.md
- Category: Security and privacy

Calls:

- `keys.encrypt()`
- `keys.decrypt()`
- `keys.forUser()`
- `keys.destroyUser()`
- `keys.rotate()`
- `keys.sign({ profile })`
- `keys.verify({ profile, publicKey, data, signature })`
- `new Keys(opts)`
- `new LocalKms(master)`
- `new MemoryKeyStore()`
- `keysFromConfig(cfg, deps)`
- `keys.forTenant(tenant, { region? })`
- `keys.destroyTenant(tenant)`
- `keys.rewrap(sealed, { purpose })`
- `keys.raw()`

In main.yaml:

```yaml
keys:
  driver: aws-kms
  key: { us: arn:aws:kms:us-east-1:…, eu: arn:aws:kms:eu-west-1:…, in: arn:aws:kms:ap-south-1:… }
  per-user: true
  rotate: 365d
```

Guarantees, whatever the driver:

- A data key per record, wrapped by a key that never leaves the KMS
- One wrapping key per region
- Every key use audited, never the key material
- Automatic rotation with old data still readable


## Drivers

Pick one in main.yaml with `keys.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| AWS KMS | `aws-kms` | yes |  |
| Google Cloud KMS | `gcp-kms` |  |  |
| Azure Key Vault | `azure-keyvault` |  |  |
| Vault Transit | `vault-transit` |  |  |
| Local (development only) | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-KEYS-0001` | check | Local keys driver in production |
| `MAIN-KEYS-0021` | check | `keys.pkcs11` is incomplete or its PIN is a literal (400). Fix: set `keys.pkcs11.pin` to `secret:HSM_PIN` and name the module and `keys.key` |
| `MAIN-KEYS-0023` | check | A FIPS claim has no level or CMVP certificate, or is made on a driver that is not an HSM (400). Fix: state `keys.fips` from the vendor's certificate, or remove it |
| `MAIN-KEYS-0002` | runtime |  |
| `MAIN-KEYS-0003` | runtime |  |
| `MAIN-KEYS-0004` | runtime | Data can't be decrypted because its owner's key was destroyed (erasure) |
| `MAIN-KEYS-0005` | runtime |  |
| `MAIN-KEYS-0006` | runtime |  |
| `MAIN-KEYS-0020` | runtime |  |
| `MAIN-KEYS-0022` | runtime |  |
| `MAIN-KEYS-0024` | runtime |  |
| `MAIN-KEYS-0025` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Chain signing profiles (secp256k1 | 2 | 0.7 | `web3-ready` |
| keys@1 contract | 1 | 0.3 | `keys#contract` |
| Envelope encryption with AES-256-GCM | 1 | 0.3 | `keys#envelope` |
| Per-user keys and crypto-erasure | 1 | 0.3 | `keys#per-user` |
| Key use audit | 1 | 0.3 | `keys#use-log` |
| Automatic rotation | 1 | 0.3 | `keys#rotation` |
| AWS KMS, Vault Transit and local drivers | 1 | 0.3 | `keys#drivers-aws-vault` |
| Google Cloud KMS and Azure Key Vault drivers | 1 | 0.3 | `keys#drivers-gcp-azure` |
| HSM drivers through PKCS#11 and KMIP | 3 | 1.2 | `hsm-keys` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Rotate or revoke a key everywhere during an incident |


## Quick start

Point `keys` at your KMS with one wrapping key per region.

```yaml title="main.yaml"
keys:
  driver: aws-kms
  key:
    us: arn:aws:kms:us-east-1:111122223333:alias/pinecart-us
    eu: arn:aws:kms:eu-west-1:111122223333:alias/pinecart-eu
    in: arn:aws:kms:ap-south-1:111122223333:alias/pinecart-in
  per-user: true
  rotate: 365d
```

TypeScript:

```ts
import { keys } from "mainsdk";

export async function sealNote(note: string, userId: string) {
  return keys.encrypt(note, { owner: userId, region: "eu", label: "phi" });
  // { v, alg: "AES-256-GCM", ciphertext, wrappedKey, keyId: "user:<id>", kmsKeyId, version, region, label }
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
let sealed = app.keys().for_user("u1").encrypt(b"secret").await?;
assert_eq!(app.keys().for_user("u1").decrypt(&sealed).await?, b"secret");
app.keys().destroy_user("u1").await?;
assert_eq!(app.keys().for_user("u1").decrypt(&sealed).await.unwrap_err().code, ErrorCode::MainKeys0004);
Ok::<_, MainError>(())
}).await?;
```


`owner` is the user id as a string. The label is one of `personal`, `sensitive`, `child` or `phi`.

KMS credentials come from workload identity or the [`secrets`](https://mainsdk.dev/docs/interfaces/secrets) driver, never from code. A cloud driver needs your KMS client: wrap it with its adapter and pass it as the `kms` option when you create the app (see [Swapping drivers](#swapping-drivers)).

## Configuration

```yaml title="main.yaml"
keys:
  driver: aws-kms          # aws-kms | gcp-kms | azure-keyvault | vault-transit | local
  key:                     # one wrapping key per region; a record is wrapped with its owner's region key
    us: arn:aws:kms:us-east-1:…
    eu: arn:aws:kms:eu-west-1:…
    in: arn:aws:kms:ap-south-1:…
  per-user: true           # a key per person, for crypto-erasure
  per-tenant: false        # a key per tenant; destroyed when the tenant is erased
  rotate: 365d             # automatic rotation period
```

| Key | Type | What it does |
| --- | --- | --- |
| `driver` | string | The key service |
| `key.us`, `key.eu`, `key.in` | KMS key identifier | The wrapping key for each region. Regions are `us`, `eu` and `in` |
| `per-user` | boolean | Gives each person their own key so `keys.destroyUser()` erases all their data |
| `per-tenant` | boolean | Gives each tenant its own key, used when `owner` is not passed. Erasing a tenant destroys it |
| `rotate` | duration | A person's or tenant's key gets a new version on its next use once the newest is this old. Old data stays readable |

With the `local` driver, `key` may name a secret that holds the master key. Without it, the master key is random per process and sealed data cannot be read after a restart, so set it for anything you keep.

### Overlays by environment

Use the local driver while you develop, and a real KMS in production. `mainsdk check` refuses the local driver in production.

```yaml title="main.env.dev.yaml"
keys:
  driver: local
```

```yaml title="main.env.prod.yaml"
keys:
  driver: aws-kms
```

## What you get

Every record or file gets its own data key, and that data key is wrapped by a key that lives in your cloud KMS and never leaves it. There is one wrapping key per region, and with per-user keys each person has their own key, so deleting it erases them everywhere. Most of the time you don't call `keys` directly: [`db`](https://mainsdk.dev/docs/interfaces/db) fields with `encrypt: true`, [`files`](https://mainsdk.dev/docs/interfaces/files) and [`crypto`](https://mainsdk.dev/docs/interfaces/crypto) use it for you. Reach for it when you seal a value yourself or need crypto-erasure.

| Call | What it does |
| --- | --- |
| `keys.encrypt(data, { owner?, region, label })` | Seal a value under a fresh data key wrapped by the owner's key. Returns a `Sealed` value. |
| `keys.decrypt(sealed, { purpose })` | Open a sealed value. The purpose goes to the audit log with the key ID. Returns bytes. |
| `keys.forUser(userId, { region? })` | Make or fetch a person's key. Returns a `KeyRef`, never key material. |
| `keys.destroyUser(userId)` | Destroy a person's key for good: everything sealed under it fails with `MAIN-KEYS-0004`. |
| `keys.rotate({ keyId? })` | Give a key a new version. Old data stays readable. |
| `keys.rewrap(sealed, { purpose })` | Wrap the data key again under the newest version and leave the ciphertext alone. |
| `keys.raw()` | The KMS client, for a feature only your KMS has. |

### Tenant keys

With `per-tenant: true`, each tenant has its own key under the region key, like a person's key.

| Call | What it does |
| --- | --- |
| `keys.forTenant(tenant, { region? })` | Make or fetch the key of one tenant. The first call creates it. Returns a `KeyRef` (`id`, `version`, `region`, `kmsKeyId`, `createdAt`), never the key. `region` defaults to `us`, or the first configured region. |
| `keys.destroyTenant(tenant)` | Destroy the tenant's key for good. Everything sealed under it fails with `MAIN-KEYS-0004`. Returns a receipt with the key id, the time and how many versions were destroyed. [`tenant`](https://mainsdk.dev/docs/interfaces/tenant) calls it when a tenant is erased. |

### Build keys yourself

The app's `keys` is built from `main.yaml`. Tests and tooling can build one directly.

| Call | What it does |
| --- | --- |
| `keysFromConfig(cfg, deps)` | Build `Keys` from the `keys` section of `main.yaml`. `deps` gives `secret` (resolves a `secret:NAME` reference), `env`, and for a vendor driver a `client`. The `local` driver is refused in `prod` with `MAIN-KEYS-0001`. |
| `new Keys(opts)` | Build `Keys` from a KMS `client`, the KMS key id of each `regions` entry, and optional `perUser`, `perTenant`, `rotateMs`, `store`, `log`, `now` and `tenant`. |
| `new LocalKms(master)` | The local driver's KMS client. It derives keys from a master secret of at least 16 characters with HKDF. For development only. |
| `new MemoryKeyStore()` | An in-memory store for wrapped scope keys, for tests and local runs. A custom store implements `get`, `put` and `list`, and must stay out of the backups of the data it protects. |

```ts title="test/keys.test.ts"
import { Keys, MemoryKeyStore } from "mainsdk/keys";
import { LocalKms } from "mainsdk/kms";

const keys = new Keys({
  client: new LocalKms("a-test-master-secret-of-16-or-more"),
  regions: { eu: "local:eu" },
  perTenant: true,
  store: new MemoryKeyStore(),
});
await keys.forTenant("tnt_acme", { region: "eu" });
```

### Sign with a key

`keys.sign()` signs with a key held by your key service, so the private key never reaches your app. A profile picks the algorithm, such as `secp256k1` for chains or `ed25519`. Each use is logged without the key. The local driver signs both profiles; `keys.verify` checks a signature against the public key `sign` returns.

```ts title="app/lib/settle.ts"
import { keys } from "mainsdk";

export async function signOrder(bytes: Uint8Array) {
  const { signature, publicKey } = await keys.sign({ profile: "secp256k1", data: bytes, purpose: "settle-order", keyId: "treasury" });
  return keys.verify({ profile: "secp256k1", publicKey, data: bytes, signature });   // true
}
```

On a vendor driver that does not sign, `keys.sign` fails with `MAIN-KEYS-0003`; use the local driver in development. An unknown profile fails the same way.

## Examples

### Seal and open a value

`keys.decrypt()` takes a purpose, which goes to the audit log with the key ID. It returns bytes.

```ts title="app/api/notes/[id]/route.ts"
import { auth, keys } from "mainsdk";
import type { Sealed } from "mainsdk/keys";

declare function loadSealedNote(user: string, id: string): Promise<Sealed>;   // your storage

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;
  const sealed = await loadSealedNote(session.user, id);   // session.user is the user id

  const plain = await keys.decrypt(sealed, { purpose: "show note to owner" });   // Uint8Array
  return Response.json({ note: new TextDecoder().decode(plain) });
}
```

> **Tip:** For database fields you don't need this: set `encrypt: true` on the field in `db.fields` and `db` seals and opens it with the owner's region key.

### Erase a person with one call

With `per-user: true`, every record sealed for a person uses their own key. Destroying it makes all of it unreadable, including copies in backups that can't be deleted at once.

```ts title="app/api/admin/users/[id]/key/route.ts"
import { auth, keys, log } from "mainsdk";

export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req, { role: "admin", fresh: "5m" });
  const { id } = await params;
  const receipt = await keys.destroyUser(id);   // { keyId, scope, destroyedAt, versions }
  log.info("user key destroyed", { userId: id });
  return Response.json(receipt);
}
```

Reading a destroyed person's data afterwards fails with `MAIN-KEYS-0004`. For a full erasure across every driver, with a signed record, call [`privacy.erase()`](https://mainsdk.dev/docs/interfaces/privacy), which uses this for backups.

### Rotate keys

With `rotate` set, a person's or tenant's key gets a new version on its next use once the newest is old enough. Old data still decrypts. To move a value to the new version, `keys.rewrap(sealed, { purpose })` wraps its data key again and leaves the ciphertext alone. You can also rotate on demand, for example from a Hono admin API after an incident:

```ts title="server/admin.ts"
import { Hono } from "hono";
import { auth, keys } from "mainsdk";

const app = new Hono();

app.post("/admin/keys/rotate", async (c) => {
  await auth.require(c.req.raw, { role: "admin", aal: "aal2", fresh: "5m" });
  const report = await keys.rotate();               // or keys.rotate({ keyId: "user:u1" })
  // { rotated: [{ keyId, from, to }], at }
  return c.json(report);
});

export default app;
```

### Look up a person's key

```ts title="app/lib/keys.ts"
import { keys } from "mainsdk";

export async function userKey(userId: string) {
  return keys.forUser(userId, { region: "eu" });   // a KeyRef, never key material; the first call creates the key
}
```

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-KEYS-0001` | The local driver is set for production | Set a KMS driver in `main.env.prod.yaml` |
| `MAIN-KEYS-0002` | The label is not `personal`, `sensitive`, `child` or `phi`; `pan` is refused because card numbers are never stored (400) | Use a canonical label; hand card data to [`pay`](https://mainsdk.dev/docs/interfaces/pay) |
| `MAIN-KEYS-0003` | A bad region, no KMS key for the region, `decrypt` without a purpose, or a person pinned to another region (400) | Pass `us`, `eu` or `in`, add `keys.key.<region>` and a `purpose` |
| `MAIN-KEYS-0004` | Decrypting data whose per-user key was destroyed (410) | Expected after erasure; remove references to the erased data |
| `MAIN-KEYS-0005` | The key service did not answer (502, retryable) | Check the KMS is reachable and the app may use the key, then retry |
| `MAIN-KEYS-0006` | A sealed value or its wrapped key failed its integrity check (400) | The value was changed or belongs to another key; restore it from its source |
| `MAIN-KEYS-0020` | The HSM or KMIP server has no key with that label or version (404) | Create the key, turn on provisioning, or restore the version |
| `MAIN-KEYS-0021` | `keys.pkcs11` is incomplete or its PIN is a literal | Set `keys.pkcs11.pin` to `secret:HSM_PIN` and name the module and `keys.key` |
| `MAIN-KEYS-0022` | An HSM wrapping key is extractable or not sensitive (500) | Create it with `CKA_EXTRACTABLE` false and `CKA_SENSITIVE` true |
| `MAIN-KEYS-0023` | A FIPS claim has no level or certificate, or is made on a driver that is not an HSM | State `keys.fips` from the vendor's certificate, or remove it |
| `MAIN-KEYS-0024` | The KMIP server refused an operation or sent a malformed message (502) | Give the app permission to use the key and check the endpoint |
| `MAIN-KEYS-0025` | The customer disabled or revoked its key (403) | Ask the customer to enable the key; other orgs are unaffected |

## Swapping drivers

Change one line. Your `keys.encrypt()`, `decrypt()`, `destroyUser()` and `rotate()` calls, and every `db` and `files` field that uses them, stay the same.

```yaml title="main.yaml"
keys:
  driver: vault-transit   # was aws-kms; point key.us, key.eu and key.in at the new service's keys
```

A cloud driver also needs a client. The adapters `awsKmsDriver`, `gcpKmsDriver`, `azureKeyVaultDriver` and `vaultTransitDriver` take the few calls they use from your vendor client, so MainSDK adds no dependency. You pass the result as `kms`:

```ts title="app/setup.ts"
import { awsKmsDriver, configure } from "mainsdk";
import type { AwsKmsApi } from "mainsdk/kms";

// Your vendor client (for example `KMSClient` from `@aws-sdk/client-kms`) wrapped in the two calls the driver uses.
declare const aws: AwsKmsApi;

configure({ kms: awsKmsDriver(aws) });
```

> **Warning:** Wrapping keys live in the KMS, so data sealed under the old service still needs that service to unwrap. Plan the move as a rewrap of existing data keys rather than a switch. A guided migration between KMS drivers is not specified yet.

Hardware security modules through PKCS#11 or KMIP, and keys your enterprise customers hold themselves, come in phase 3 as a `main.yaml` change with the same envelope encryption. When you need a KMS-only feature, `keys.raw()` returns the KMS client.

## Testing

The local driver needs no KMS account. `createTestApp` makes it the app behind `keys`.

```ts title="app/lib/notes.test.ts"
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { keys } from "mainsdk";

describe("keys", () => {
  it("keys AC3 makes a destroyed user's data unreadable", async () => {
    createTestApp({ yaml: "schema: 1\nproject: prj_test\nkeys:\n  driver: local\n  per-user: true\n" });
    const sealed = await keys.encrypt("note", { owner: "u1", region: "eu", label: "phi" });

    const receipt = await keys.destroyUser("u1");
    assert.equal(receipt.keyId, "user:u1");
    await assert.rejects(keys.decrypt(sealed, { purpose: "test" }), { code: "MAIN-KEYS-0004" });
  });
});
```

## Security and privacy

- Data keys use AES-256-GCM. MD5, SHA-1, DES and home-made crypto are never used.
- Plaintext data keys are zeroed after use and never logged or cached beyond one call.
- Every wrap, unwrap, rewrap, destroy and rotate is written to the audit log (`keys.wrap`, `keys.use`, `keys.rewrap`, `keys.destroy`, `keys.rotate`) with key ID, version and purpose, never the key material.
- One wrapping key per region keeps sealed data tied to its owner's region.

### What Cloud adds

On the Fleet plan, rotate or revoke a key everywhere during an incident. Encryption, per-user keys, crypto-erasure and rotation in your app are free. See [Free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**Do I need to call `keys` myself?** Rarely. `db` fields with `encrypt: true`, `files` and `crypto` use it for you.

**Can I read old data after rotation?** Yes. Old versions stay usable for decrypt. Call `keys.rewrap()` to move a value to the new version.

**Does erasure reach backups?** Yes, when `per-user` is on: the backup still holds ciphertext, but the key to open it is gone. Keep the key store out of backups of the data it protects, or a copy of a destroyed key survives.

## Next

- [Data and records](https://mainsdk.dev/docs/interfaces/db): encrypted fields on your models.
- [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy): erase a person across every driver.
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets): where KMS credentials come from.
