Encryption keys
keysEncrypt each record with its own data key, wrapped by a regional KMS key, with per-user keys so one call erases a person everywhere.
keysFree in your app, Apache-2.0Status 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.
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: 365dimport { keys } from "mainsdk";
const sealed = await keys.encrypt(note, { owner: user, region: "eu", label: "phi" });
const receipt = await keys.destroyUser(user); // crypto-erasureapp/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" });
});
});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()
Drivers
5 drivers. Select one to see its line in main.yaml.
keys:
driver: aws-kmsChange this one line to swap providers; your calls stay the same.AWS KMS integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-KEYS-0001 | Checkmainsdk check, before deploy | Local keys driver in production |
MAIN-KEYS-0021 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | 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 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0003 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0004 | RuntimeError responses and logs | Data can't be decrypted because its owner's key was destroyed (erasure) |
MAIN-KEYS-0005 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0006 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0020 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0022 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0024 | RuntimeError responses and logs | See the spec |
MAIN-KEYS-0025 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Chain signing profiles (secp256k1 | Phase 2 | 0.7 | web3-ready |
| keys@1 contract | Phase 1 | 0.3 | keys#contract |
| Envelope encryption with AES-256-GCM | Phase 1 | 0.3 | keys#envelope |
| Per-user keys and crypto-erasure | Phase 1 | 0.3 | keys#per-user |
| Key use audit | Phase 1 | 0.3 | keys#use-log |
| Automatic rotation | Phase 1 | 0.3 | keys#rotation |
| AWS KMS, Vault Transit and local drivers | Phase 1 | 0.3 | keys#drivers-aws-vault |
| Google Cloud KMS and Azure Key Vault drivers | Phase 1 | 0.3 | keys#drivers-gcp-azure |
| HSM drivers through PKCS#11 and KMIP | Phase 3 | 1.2 | hsm-keys |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| 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.
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: 365dapp/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 }
}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 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
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.
keys:
driver: localkeys:
driver: aws-kmsWhat 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.
| 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 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. |
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.
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.
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.
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:
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
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 |
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.
keys:
driver: vault-transit # was aws-kms; point key.us, key.eu and key.in at the new service's keysA 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:
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.
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
- Data and records: encrypted fields on your models.
- Privacy and data rights: erase a person across every driver.
- Secrets: where KMS credentials come from.
Was this page helpful?