Skip to content

stubbies/cryptonest

Repository files navigation

cryptonest

CI npm Version

NestJS library for AES-256-GCM field encryption, key rotation, optional KMS / hybrid envelope drivers, blind indexing (HMAC-SHA256), and HTTP interceptors driven by @Encrypted() metadata.

Requirements

  • Node.js ≥ 20 (LTS; recommended 22 — see .nvmrc)
  • NestJS ≥ 9 (@nestjs/common, @nestjs/core)
  • reflect-metadata, class-transformer (peer dependencies)
  • Optional peers (install only what you use):
    • AWS KMS: @aws-sdk/client-kms
    • GCP KMS: @google-cloud/kms

Features

  • Local driver (default): symmetric encryption with a key ring (keys); first entry encrypts; decrypt tries keys in order for rotation.
  • AWS KMS: GenerateDataKey + AES-GCM payload; v1: envelope with wrapped DEK. Subpath cryptonest/aws-kms avoids loading the AWS SDK for local-only apps.
  • Google Cloud KMS: local DEK encrypted with KMS; v2:gcp-kms: envelope. Subpath cryptonest/gcp-kms. Optional gcpKms.logicalKeyId for the envelope key id segment (defaults to keyName).
  • HashiCorp Vault Transit: data key from Transit + AES-GCM; v2:vault-transit: envelope. Subpath cryptonest/vault-transit.
  • Hybrid RSA (RSA-OAEP-256 + AES-GCM): v2:rsa-oaep-256: envelope; key ring or hybridRsa PEM defaults. Subpath cryptonest/hybrid-rsa.
  • Blind indexing: EncryptionService.hashSearchTerm() / KeyRotationManager.generateBlindIndex() when blindIndexSecret (≥ 32 bytes) is set. On DTOs, @Encrypted({ blindIndex: true }) (or { blindIndex: 'yourPropName' }) sets a companion HMAC field on toPlain (response encryption).
  • DTOs & interceptors: @Encrypted() on properties; @EncryptedRoute({ bodyDto, responseDto }) with DecryptionInterceptor / EncryptionInterceptor. Nested objects are supported when class-transformer’s @Type(() => ChildDto) is set; depth is capped by interceptorMaxDepth (default 8, via EncryptionModule options).
  • EncryptionModule: global dynamic module with forRoot and forRootAsync.

Installation

npm install cryptonest @nestjs/common @nestjs/core class-transformer reflect-metadata
# Optional:
npm install @aws-sdk/client-kms
npm install @google-cloud/kms

Build before local linking: npm run build (outputs dist/).

Module registration

EncryptionModule is @Global(). Use forRoot or forRootAsync.

Local driver (default)

import { EncryptionModule } from 'cryptonest';

@Module({
  imports: [
    EncryptionModule.forRoot({
      keys: [
        { id: 'k2', secret: process.env.ENCRYPTION_KEY! }, // current — min 32 bytes
        { id: 'k1', secret: process.env.OLD_ENCRYPTION_KEY! }, // previous — tried on decrypt
      ],
      blindIndexSecret: process.env.BLIND_INDEX_SECRET, // optional; ≥ 32 bytes if set
      interceptorMaxDepth: 8, // optional; nested @Type DTO walk depth (default 8)
    }),
  ],
})
export class AppModule {}

Async registration

EncryptionModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: async (config: ConfigService) => ({
    keys: [{ id: 'k1', secret: config.getOrThrow('ENCRYPTION_KEY') }],
    blindIndexSecret: config.get('BLIND_INDEX_SECRET'),
  }),
}),

AWS KMS

Envelope drivers do not use keys for crypto; pass keys: [] if you only use KMS. awsKms.logicalKeyId (optional) is written into the ciphertext envelope; awsKms.keyId is the CMK id/ARN for GenerateDataKey.

import { KMSClient } from '@aws-sdk/client-kms';
import { EncryptionModule } from 'cryptonest';

EncryptionModule.forRoot({
  driver: 'aws-kms',
  keys: [],
  awsKms: {
    keyId: process.env.KMS_KEY_ID!,
    logicalKeyId: process.env.KMS_LOGICAL_KEY_ID, // optional; defaults to keyId
    client: new KMSClient({}),
  },
  blindIndexSecret: process.env.BLIND_INDEX_SECRET,
}),

Direct driver import (optional): import { createAwsKmsDriver } from 'cryptonest/aws-kms'.

Google Cloud KMS

Wire @google-cloud/kms to GcpKmsClientLike (exported from cryptonest), for example:

import { KeyManagementServiceClient } from '@google-cloud/kms';
import { EncryptionModule } from 'cryptonest';
import type { GcpKmsClientLike } from 'cryptonest';

const kms = new KeyManagementServiceClient();

const client: GcpKmsClientLike = {
  async encrypt({ name, plaintext }) {
    const [result] = await kms.encrypt({
      name,
      plaintext: Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext),
    });
    return { ciphertext: Buffer.from(result.ciphertext as Uint8Array) };
  },
  async decrypt({ name, ciphertext }) {
    const [result] = await kms.decrypt({
      name,
      ciphertext,
    });
    return { plaintext: Buffer.from(result.plaintext as Uint8Array) };
  },
};

EncryptionModule.forRoot({
  driver: 'gcp-kms',
  keys: [],
  gcpKms: {
    keyName:
      'projects/…/locations/…/keyRings/…/cryptoKeys/…/cryptoKeyVersions/1',
    logicalKeyId: 'optional-stable-id', // optional; defaults to keyName
    client,
  },
  blindIndexSecret: process.env.BLIND_INDEX_SECRET,
});

Subpath: cryptonest/gcp-kms.

HashiCorp Vault Transit

Provide a small adapter implementing VaultTransitClientLike (datakeyPlaintext, decrypt). Subpath: cryptonest/vault-transit.

EncryptionModule.forRoot({
  driver: 'vault-transit',
  keys: [],
  vaultTransit: {
    transitKeyName: 'my-key',
    client: vaultTransitAdapter,
  },
  blindIndexSecret: process.env.BLIND_INDEX_SECRET,
}),

Hybrid RSA (RSA-OAEP-256 + AES-GCM)

Use driver: 'hybrid-rsa' with keys (PEM in each secret) and/or hybridRsa.publicKey / hybridRsa.privateKey. Subpath: cryptonest/hybrid-rsa.

EncryptionModule.forRoot({
  driver: 'hybrid-rsa',
  keys: [{ id: 'k1', secret: process.env.RSA_PRIVATE_KEY_PEM! }],
  hybridRsa: { logicalKeyId: 'default-key' },
  blindIndexSecret: process.env.BLIND_INDEX_SECRET,
}),

Manual encryption

Inject EncryptionService or KeyRotationManager.

import { EncryptionService } from 'cryptonest';

@Injectable()
export class UsersService {
  constructor(private readonly encryption: EncryptionService) {}

  async saveSsn(plain: string) {
    const ciphertext = await this.encryption.encryptField(plain);
    // persist ciphertext (v1 / v2 envelope)
  }

  async loadSsn(stored: string) {
    return this.encryption.decryptField(stored);
  }
}
  • encryptField / decryptField: null/empty safe; decryptField only decrypts values that look like library ciphertext (v1: or v2: prefixes); other strings are returned unchanged.
  • hashSearchTerm: deterministic HMAC for equality queries (requires blindIndexSecret on the module).

Lower-level KeyRotationManager.encrypt / .decrypt follow the configured driver (including full key-ring trial decrypt for local / hybrid).

Blind indexing (searchable encrypted columns)

const index = await this.encryption.hashSearchTerm(email);
// store `index` in e.g. email_blind_index

const search = await this.encryption.hashSearchTerm(userInput);
// query WHERE email_blind_index = search

For responses, @Encrypted({ blindIndex: true }) encrypts the field and sets ${propertyName}BlindIndex to the same HMAC (plaintext must not already be ciphertext). Use { blindIndex: 'customColumnName' } to choose the companion key.

DTOs, nested DTOs, and interceptors

  1. Mark fields with @Encrypted() (and optional blindIndex).
  2. For nested objects, use @Type(() => ChildDto) from class-transformer on the parent property.
  3. On the route, @EncryptedRoute({ bodyDto, responseDto }).
  4. Register DecryptionInterceptor and/or EncryptionInterceptor.
import { Controller, Post, Body, UseInterceptors } from '@nestjs/common';
import { Type } from 'class-transformer';
import {
  Encrypted,
  EncryptedRoute,
  DecryptionInterceptor,
  EncryptionInterceptor,
} from 'cryptonest';

class ChildDto {
  @Encrypted()
  note!: string;
}

class CreateUserDto {
  @Encrypted()
  phone!: string;

  @Type(() => ChildDto)
  meta!: ChildDto;
}

@Controller('users')
@UseInterceptors(DecryptionInterceptor, EncryptionInterceptor)
export class UsersController {
  @Post()
  @EncryptedRoute({ bodyDto: CreateUserDto, responseDto: CreateUserDto })
  create(@Body() body: CreateUserDto) {
    return body;
  }
}

For tests or custom flows: applyEncryptedFieldTransforms(encryption, DtoClass, plain, 'toClass' | 'toPlain') (async).

Ciphertext wire format

Driver Prefix / shape
Local v1: + b64url key id, iv, ciphertext, tag
AWS KMS v1: + key id, wrapped DEK, iv, ciphertext, tag
GCP KMS v2:gcp-kms: + …
Vault Transit v2:vault-transit: + …
Hybrid RSA v2:rsa-oaep-256: + …

Details: src/lib/envelope.ts (serialize* / parseEnvelope).

Package exports

  • cryptonest: module, service, interceptors, envelope helpers, decorators.
  • cryptonest/aws-kms, cryptonest/gcp-kms, cryptonest/vault-transit, cryptonest/hybrid-rsa: driver entry points (optional SDK loading).

Security notes

  1. Use ≥ 32-byte secrets for AES keys and for blindIndexSecret.
  2. Keep keys in a secret manager; never commit them.
  3. Put the current key first in keys; older keys are decrypt-only during rotation.
  4. With KMS, plaintext still exists in application memory during encrypt/decrypt; use least-privilege on CMK / service accounts.
  5. Envelope drivers validate that the ciphertext key id segment matches the configured logical key where applicable (AWS, GCP, Vault).

License

MIT

About

NestJS library: AES-256-GCM field encryption, key rotation, AWS/GCP KMS & Vault Transit & hybrid RSA drivers, blind indexing, and HTTP interceptors

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors