@boolab/crypto-gateway
v1.0.0
Published
A thin envelope-crypto gateway that leases data keys and audits locally, while the authoritative grant/deny decision stays in a customer-owned key system.
Maintainers
Readme
@boolab/crypto-gateway
A thin envelope-encryption gateway. It hands out managed data-key handles and audits every operation locally, but it never holds key material and it never decides who may decrypt.
The decryption authority lives in a customer-owned key system (AWS KMS, GCP KMS, HashiCorp Vault/OpenBao, etc.) on the critical path of every unwrap. The gateway cannot grant access it was never given: there is no approval gate to bypass, because access is enforced cryptographically by infrastructure the customer controls.
Install
npm install @boolab/crypto-gatewayConcepts
KeyProvider: an adapter to the customer's key system. It custodies the KEK only: it wraps, unwraps, and re-wraps data keys, but never mints them. It is intentionally "dumb": it relays operations and surfaces the key system's verdict unchanged, and must never synthesize agranted.AuditSink: where the host writes its local audit trail. The gateway calls it for every operation and is fail-closed: if the audit write fails, the operation fails. This is not the record of record; the customer's key system keeps its own authoritative log.CryptoGateway: what the host calls. It leases and audits; it does not authorize.DataKey: a managed handle returned on a grant. You reach the plaintext throughkey.use(fn), never as a field. Access goes through the handle so the TTL is re-checked on every call; an expired key is transparently re-unwrapped first, which re-runs the key system's authorization. A refresh returns the same plaintext (the key is reused, not rotated); its point is to bound how long access survives after the key system revokes it.
An unwrap returns one of two verdicts, both of which the caller must handle:
| Verdict | Meaning |
| ----------- | --------------------------------------------------------------- |
| granted | The key system released the key; the result carries a DataKey. |
| denied | The key system refused (policy, revoked key). |
key.use(fn) returns the same verdict: on granted it carries your callback's
value; on a refresh that is no longer granted it returns denied and your
callback never runs.
Usage
The core entry point (@boolab/crypto-gateway) holds the gateway and the
interfaces, with no bundled adapters. The reference adapters live behind a
separate import (@boolab/crypto-gateway/adapters).
import { createCryptoGateway, type CallerContext } from '@boolab/crypto-gateway';
import { InMemoryKeyProvider, InMemoryAuditSink } from '@boolab/crypto-gateway/adapters';
const gateway = createCryptoGateway({
keyProvider: new InMemoryKeyProvider(), // swap for a customer-owned adapter
auditSink: new InMemoryAuditSink(), // swap for a SIEM / append-only store
leaseTtlSeconds: 300,
});
// The gateway trusts this context as-is and forwards it as the authorization
// subject, so the host must build it at its trust boundary from authenticated,
// server-observed data (verified token claims, an mTLS cert, the socket),
// not from request fields the caller controls.
const context: CallerContext = {
principal: 'workload://content-service',
customerId: 'cust-1',
sourceAddress: '10.0.0.1',
requestId: 'req-abc123',
};
// Encrypt new content: persist key.wrapped alongside the ciphertext.
const key = await gateway.issueDataKey(context);
await key.use((plaintext) => encrypt(content, plaintext));
// Decrypt stored content: handle both verdicts.
const result = await gateway.openDataKey(context, key.wrapped);
switch (result.status) {
case 'granted':
// The TTL is re-checked here; a stale key re-unwraps before `fn` runs.
await result.key.use((plaintext) => decrypt(content, plaintext));
break;
case 'denied':
fail(result.reason); // the key system said no
break;
}The plaintext is reachable only inside use, and only for the duration of the
callback. Don't hoist it into a longer-lived variable: that would bypass the
TTL re-check and the re-authorization that comes with it.
Whether a given principal is auto-granted or routed to human approval is decided entirely by the customer's key-system policy. The library never makes that call.
Writing an adapter
Implement KeyProvider against the customer's key system. The bundled
InMemoryKeyProvider (AES-256-GCM, KEK held in process memory) is a working
reference for tests, examples, and local development, not for production,
where the KEK must live in a key system the customer controls.
import type { KeyProvider } from '@boolab/crypto-gateway';
class MyKmsProvider implements KeyProvider {
// KEK encrypt: wrap a caller-supplied plaintext data key (e.g. kms:Encrypt).
wrapDataKey(context, plaintext) { /* ... */ }
// granted yields { status: 'granted', plaintext }; otherwise surface the
// key system's verdict unchanged, never synthesize a grant.
unwrapDataKey(context, wrapped) { /* ... */ }
rewrapDataKey(context, wrapped) { /* re-wrap to current KEK version */ }
}The provider custodies the KEK only; it never mints data keys. The gateway
mints them with a CSPRNG in issueDataKey and asks the provider to wrap them,
and it owns the lease TTL (a granted unwrap returns plaintext, which the
gateway wraps in a refreshing DataKey).
Migrating a data key between providers
Because data keys are minted by the caller and providers only wrap them, a
wrapped key can be re-homed from one provider to another without re-encrypting
the content it protects. Open it under the old gateway and re-wrap it under the
new one; the plaintext data key only exists inside the use scope:
const opened = await gatewayA.openDataKey(context, wrappedUnderA);
if (opened.status !== 'granted') return opened; // denied
const result = await opened.key.use((dataKey) => gatewayB.wrapDataKey(context, dataKey));
if (result.status !== 'granted') return result;
const wrappedUnderB = result.value; // persist this; same data key, same ciphertextSealing content (@boolab/crypto-gateway/content)
The gateway stops at the data-key boundary. The content module is its generic
companion: a self-describing AES-256-GCM envelope so you don't hand-roll the
framing. The core runs inside DataKey.use, so the plaintext key is never
returned to the caller.
import { sealWithDataKey, openWithDataKey, isSealed } from '@boolab/crypto-gateway/content';
const key = await gateway.issueDataKey(context, { keyId: 'dek-1' });
const sealed = await sealWithDataKey(key, new TextEncoder().encode('secret'));
if (sealed.status !== 'granted') return sealed; // denied
// sealed.value is the envelope: persist it as-is.
const opened = await openWithDataKey(key, sealed.value);
if (opened.status === 'granted') {
opened.value.keyId; // 'dek-1', read from the envelope
opened.value.plaintext; // the original bytes
}The envelope is magic | version | keyIdLen | keyId | iv(12) | tag(16) | ciphertext.
The GCM tag authenticates the ciphertext only — the framing is not bound as
AAD. A fixed magic prefix (isSealed) lets a reader tell a sealed blob from
legacy plaintext, which makes a read-path passthrough and on-boot migration
safe. sealContent / openContent expose the same logic as pure, synchronous
functions when you already hold the raw 32-byte key.
Keyrings (@boolab/crypto-gateway/keyring)
A keyring persists one data key wrapped under N providers at once
(primary + fallbacks), so content stays recoverable through any of them while
the envelope keyId stays constant. The module is codec + algorithms only: it
never does I/O and treats each binding's provider descriptor as opaque (typed
P), so it never depends on your provider schema. You supply a GatewayFor<P>
that turns a descriptor into a gateway.
import { parseKeyring, openKeyring, type GatewayFor } from '@boolab/crypto-gateway/keyring';
const gatewayFor: GatewayFor<MyDescriptor> = async (descriptor) => {
const keyProvider = await buildProvider(descriptor); // host owns this
return createCryptoGateway({ keyProvider, auditSink, leaseTtlSeconds: 300 });
};
const keyring = parseKeyring<MyDescriptor>(JSON.parse(await readFile('dek.json', 'utf8')));
const result = await openKeyring(gatewayFor, keyring, context);
if (result.status === 'granted') {
// result.dataKey is leased through result.openedBindingId, stamped with keyring.keyId
} else {
result.perBinding; // human-readable denied/error per binding
}Also provided: serializeKeyring (canonical, order-insensitive),
orderedWrappings, pruneExpiredWrappings, probeWrapping, wrapDekUnder
(bind a new provider to an open key without re-encrypting content), and
singleBindingKeyring (the first-boot helper). Byte I/O and the provider schema
stay in the host.
Production adapters
Each production adapter lives behind its own import so you install only the SDK you use. The cloud SDKs are optional peer dependencies: add the one your adapter needs alongside this package.
Every adapter follows the same contract: it wraps/unwraps/rewraps under a KEK
the key system holds and never exposes, and it surfaces the key system's
verdict. An unwrap that the key system refuses (an access-denied error, a
disabled key) is mapped to a denied verdict; an operational failure (network,
throttling) propagates as a thrown error. Each adapter accepts a
classifyUnwrapError hook to override that mapping — for example, to recognize
a custom error as authorization-refused rather than operational.
| Adapter | Import | Peer dependency | Setup guide |
| --- | --- | --- | --- |
| Vault / OpenBao Transit | @boolab/crypto-gateway/adapters/vault | none (uses fetch) | docs/adapters/vault.md |
| AWS KMS | @boolab/crypto-gateway/adapters/aws-kms | @aws-sdk/client-kms | docs/adapters/aws-kms.md |
| GCP Cloud KMS | @boolab/crypto-gateway/adapters/gcp-kms | @google-cloud/kms | docs/adapters/gcp-kms.md |
| Azure Key Vault / HSM | @boolab/crypto-gateway/adapters/azure-keyvault | @azure/keyvault-keys | docs/adapters/azure-keyvault.md |
The brief snippets below are enough to wire each adapter up; the linked guides cover provisioning the KEK, least-privilege IAM, rotation, and the audit story end-to-end. Start at docs/adapters/ if you're evaluating.
Vault / OpenBao Transit
The closest fit: the Transit key never leaves Vault, wrapped data keys are
Vault's own vault:vN:... ciphertext, and rewrap uses Transit's native rewrap.
No SDK — it speaks the HTTP API over the global fetch (Node 18+).
import { VaultTransitKeyProvider } from '@boolab/crypto-gateway/adapters/vault';
const keyProvider = new VaultTransitKeyProvider({
address: 'https://vault.internal:8200',
token: process.env.VAULT_TOKEN!, // a renewed token for a long-lived host
keyName: 'content-kek', // the Transit key (the KEK)
// mount: 'transit', namespace: 'team-a', timeoutMs: 10_000,
});A 403 from Vault becomes denied; other non-2xx responses throw
VaultRequestError.
The caller context rides along as X-Crypto-Gateway-* request headers
(Request-Id, Principal, Source-Address, Customer-Id) for provenance.
They appear in Vault's audit log only if the operator allowlists them:
vault write sys/config/auditing/request-headers/x-crypto-gateway-request-id hmac=falseUnlike the AWS and GCP adapters, this one does not bind the tenant as
authenticated data: Vault's Transit rewrap cannot decrypt associated_data-
bound ciphertext, so binding would break native rewrap. The context is carried
for audit (the headers above) rather than bound cryptographically.
AWS KMS
Wraps with kms:Encrypt, unwraps with kms:Decrypt, rewraps with
kms:ReEncrypt — deliberately not GenerateDataKey, since data keys are
minted gateway-side and the provider only wraps them. Pass a configured
KMSClient (it carries region, credentials, and retry policy).
import { KMSClient } from '@aws-sdk/client-kms';
import { createAwsKmsKeyProvider } from '@boolab/crypto-gateway/adapters/aws-kms';
const keyProvider = createAwsKmsKeyProvider({
client: new KMSClient({ region: 'eu-west-1' }),
keyId: 'arn:aws:kms:eu-west-1:111122223333:key/abcd-…', // key id, ARN, or alias
});AccessDeniedException, DisabledException, and KMSInvalidStateException
become denied; other errors propagate.
GCP Cloud KMS
Wraps and unwraps with the symmetric encrypt / decrypt operations.
import { KeyManagementServiceClient } from '@google-cloud/kms';
import { createGcpKmsKeyProvider } from '@boolab/crypto-gateway/adapters/gcp-kms';
const client = new KeyManagementServiceClient();
const keyProvider = createGcpKmsKeyProvider({
client,
keyName: client.cryptoKeyPath('my-project', 'global', 'my-ring', 'content-kek'),
});PERMISSION_DENIED (7) and FAILED_PRECONDITION (9) become denied. Cloud KMS
has no symmetric re-encrypt, so rewrapDataKey decrypts then re-encrypts under
the key's current primary version — meaning the plaintext data key is briefly
present in this process during a rewrap.
Azure Key Vault / Managed HSM
Wraps and unwraps with wrapKey / unwrapKey (RSA-OAEP-256 by default). Pass a
CryptographyClient bound to the vault key; use an unversioned key id so
rewraps target the current version.
import { DefaultAzureCredential } from '@azure/identity';
import { CryptographyClient } from '@azure/keyvault-keys';
import { createAzureKeyVaultKeyProvider } from '@boolab/crypto-gateway/adapters/azure-keyvault';
const client = new CryptographyClient(
'https://my-vault.vault.azure.net/keys/content-kek',
new DefaultAzureCredential(),
);
const keyProvider = createAzureKeyVaultKeyProvider({ client /*, algorithm */ });HTTP 403 becomes denied. As with GCP, Key Vault has no re-wrap, so
rewrapDataKey unwraps then re-wraps and the plaintext is briefly in-process.
Bundled reference adapters
Imported from @boolab/crypto-gateway/adapters. All are reference adapters for
tests, examples, and local development, not a substitute for a customer-owned
key system.
InMemoryKeyProvider: KEK held in process memory, AES-256-GCM wrapping.InMemoryAuditSink: keeps audit events in memory.FileKeyProvider: custodies the KEK in a file so wrapped keys survive a restart. See docs/adapters/filesystem.md for the (narrow) range of production deployments it fits. Construct it with one of the async factories:import { FileKeyProvider } from '@boolab/crypto-gateway/adapters'; // First run: mint and persist a KEK (mode 0600; refuses to overwrite). const provider = await FileKeyProvider.create({ path: 'kek.json', masterKey }); // Later runs: load it back. const provider = await FileKeyProvider.open({ path: 'kek.json', masterKey });masterKeyis a 32-byte key. With it, the KEK is encrypted at rest (AES-256-GCM), so reading the file alone does not reveal it. Without it, the KEK is written in plaintext, and anyone who can read the file can unwrap every data key. Either way the KEK becomes reachable to this process, soFileKeyProvideris a single-node convenience, not a real key system.
Development
npm install
npm run typecheck
npm test # unit tests; no network, no Docker
npm run buildIntegration tests
npm run test:integration runs the production adapters against real services
spun up with Testcontainers, so it requires a
running Docker daemon. It is kept separate from npm test and is not part of
the default gate.
- Vault —
hashicorp/vaultdev server. - AWS KMS — LocalStack (
ReEncryptis skipped there; the emulator doesn't implement it, but real KMS does). - Azure Key Vault —
lowkey-vault, an emulator (binds host port 8443, which must be free). - GCP KMS has no emulator, so it has no integration test; its adapter logic is covered by the unit tests via an injected fake.
License
MIT
