MainSDK
Search⌘K
Interfaces

Security and privacy

Encryption keys

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.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec keysFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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

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

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()

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

5 drivers. Select one to see its line in main.yaml.

main.yamlAWS KMS (default)
keys:
  driver: aws-kms

Change this one line to swap providers; your calls stay the same.AWS KMS integration

Codes

CodeWhere it appearsMeaning
MAIN-KEYS-0001Checkmainsdk check, before deployLocal keys driver in production
MAIN-KEYS-0021Checkmainsdk check, before deploykeys.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-0023Checkmainsdk check, before deployA 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-0002RuntimeError responses and logsSee the spec
MAIN-KEYS-0003RuntimeError responses and logsSee the spec
MAIN-KEYS-0004RuntimeError responses and logsData can't be decrypted because its owner's key was destroyed (erasure)
MAIN-KEYS-0005RuntimeError responses and logsSee the spec
MAIN-KEYS-0006RuntimeError responses and logsSee the spec
MAIN-KEYS-0020RuntimeError responses and logsSee the spec
MAIN-KEYS-0022RuntimeError responses and logsSee the spec
MAIN-KEYS-0024RuntimeError responses and logsSee the spec
MAIN-KEYS-0025RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Chain signing profiles (secp256k1Phase 20.7web3-ready
keys@1 contractPhase 10.3keys#contract
Envelope encryption with AES-256-GCMPhase 10.3keys#envelope
Per-user keys and crypto-erasurePhase 10.3keys#per-user
Key use auditPhase 10.3keys#use-log
Automatic rotationPhase 10.3keys#rotation
AWS KMS, Vault Transit and local driversPhase 10.3keys#drivers-aws-vault
Google Cloud KMS and Azure Key Vault driversPhase 10.3keys#drivers-gcp-azure
HSM drivers through PKCS#11 and KMIPPhase 31.2hsm-keys

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
FleetRotate or revoke a key everywhere during an incident

Quick start

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

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

app/lib/notes.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 }
}

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 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).

Configuration

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
KeyTypeWhat it does
driverstringThe key service
key.us, key.eu, key.inKMS key identifierThe wrapping key for each region. Regions are us, eu and in
per-userbooleanGives each person their own key so keys.destroyUser() erases all their data
per-tenantbooleanGives each tenant its own key, used when owner is not passed. Erasing a tenant destroys it
rotatedurationA 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.

main.env.dev.yaml
keys:
  driver: local
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 fields with encrypt: true, files and crypto use it for you. Reach for it when you seal a value yourself or need crypto-erasure.

CallWhat 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.

CallWhat 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 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.

CallWhat 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.
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.

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.

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) });
}

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.

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(), 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:

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

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

CodeWhenFix
MAIN-KEYS-0001The local driver is set for productionSet a KMS driver in main.env.prod.yaml
MAIN-KEYS-0002The 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
MAIN-KEYS-0003A 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-0004Decrypting data whose per-user key was destroyed (410)Expected after erasure; remove references to the erased data
MAIN-KEYS-0005The key service did not answer (502, retryable)Check the KMS is reachable and the app may use the key, then retry
MAIN-KEYS-0006A 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-0020The 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-0021keys.pkcs11 is incomplete or its PIN is a literalSet keys.pkcs11.pin to secret:HSM_PIN and name the module and keys.key
MAIN-KEYS-0022An HSM wrapping key is extractable or not sensitive (500)Create it with CKA_EXTRACTABLE false and CKA_SENSITIVE true
MAIN-KEYS-0023A FIPS claim has no level or certificate, or is made on a driver that is not an HSMState keys.fips from the vendor's certificate, or remove it
MAIN-KEYS-0024The 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-0025The 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.

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:

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) });

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.

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet