npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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-gateway

Concepts

  • 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 a granted.
  • 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 through key.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 ciphertext

Sealing 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=false

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

    masterKey is 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, so FileKeyProvider is 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 build

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

  • Vaulthashicorp/vault dev server.
  • AWS KMS — LocalStack (ReEncrypt is skipped there; the emulator doesn't implement it, but real KMS does).
  • Azure Key Vaultlowkey-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