@cobranza-apps/crypto
v0.4.2
Published
Shared encryption (AES-256-GCM + HKDF) and deterministic hashing (HMAC-SHA256) library for Cobranza App microservices.
Downloads
88
Readme
@cobranza-apps/crypto
Shared encryption & deterministic hashing library for the Cobranza App platform. Single source of truth for protecting PII, financial, bank, and notification data across all NestJS microservices.
Overview / Purpose
@cobranza-apps/crypto is a framework-agnostic TypeScript library for Node.js (22.14.0+) providing authenticated encryption and deterministic hashing. It uses the built-in crypto module with zero runtime dependencies and enforces consistent security, best practices, and key-rotation readiness across all Cobranza App microservices.
What it does:
- AES-256-GCM authenticated encryption with per-category HKDF-SHA256 key derivation. Output format is
IV(12 bytes) + ciphertext + authTag(16 bytes), Base64-encoded. - Deterministic HMAC-SHA256 hashing for indexed PII lookups with constant-time
verifyHash. - Combined
encryptAndHashfor fields needing both ciphertext storage and a hash index. - Version-aware decryption for seamless key rotation.
What it does NOT do (non-goals):
- No password hashing — Argon2id/bcrypt belong in the Auth microservice.
- No
process.envreads; pass all config viaCryptoConfig. - No business logic, database access, or hard NestJS dependency; an optional
./nestjssubpath providesCryptoModuleandCryptoServicewhen@nestjs/commonis installed. - No browser or non-Node.js environments.
Status / Stability
All API methods are implemented; algorithms may evolve before v1.0.
Table of Contents
- Requirements
- Installation
- Getting Started
- Configuration
- Usage Examples
- Real-World Scenarios
- API Summary
- NestJS Integration Guide
- NestJS Configuration Guide (full)
- Security Best Practices
- Security Guide
- Security Checklist
- Observability & Auditing
- Key Rotation Guide
- Performance Considerations
- Testing
- Development
- License
Requirements
- Node.js 22.14.0 (see
.nvmrc) @cobranza-apps/entities— providesEncryptedValuetype and@IsEncryptedField()decorator@nestjs/common/@nestjs/config— optional peer dependencies (required only for the./nestjssubpath)
Installation
npm install @cobranza-apps/crypto @cobranza-apps/entitiesGetting Started
A minimal configuration and roundtrip in under one minute:
import { SecureCrypto, EncryptionKey } from '@cobranza-apps/crypto';
const crypto = new SecureCrypto({
masterKey: process.env.COBRANZA_CRYPTO_MASTER_KEY!, // base64 32-byte key
hashSalt: process.env.COBRANZA_CRYPTO_HASH_SALT!, // base64 >= 32 bytes
currentVersion: 1,
defaultKeyName: EncryptionKey.PII,
});
const encrypted = crypto.encrypt('hello world', EncryptionKey.PII);
const plaintext = crypto.decrypt(encrypted);
console.assert(plaintext === 'hello world');For the full walkthrough (key generation, hash, dual-column pattern) see Getting Started.
Configuration
The library accepts all configuration at instantiation time via CryptoConfig.
import { SecureCrypto, CryptoConfig, EncryptionKey } from '@cobranza-apps/crypto';
const cryptoConfig: CryptoConfig = {
masterKey: process.env.COBRANZA_CRYPTO_MASTER_KEY!, // base64 32-byte key
hashSalt: process.env.COBRANZA_CRYPTO_HASH_SALT!, // base64 >= 32 bytes
currentVersion: 1,
defaultKeyName: EncryptionKey.PII,
};
const crypto = new SecureCrypto(cryptoConfig);Usage Examples
All examples assume a configured SecureCrypto instance (see Configuration).
Encrypt
const encrypted = crypto.encrypt('[email protected]', EncryptionKey.PII);
// encrypted: {
// encryptedData: 'base64-encoded IV(12) + ciphertext + authTag(16)',
// keyName: 'pii',
// algorithm: 'aes-256-gcm',
// version: 1,
// }Decrypt
const original = crypto.decrypt(encrypted);
// '[email protected]'
// Roundtrip assertion
console.assert(original === '[email protected]', 'decrypt must restore plaintext');Hash
const emailHash = crypto.hash('[email protected]');
// base64 HMAC-SHA256; deterministic, safe for indexed lookups
// Same input always produces the same hash
console.assert(crypto.hash('[email protected]') === emailHash);verifyHash
const emailHash = crypto.hash('[email protected]');
const isValid = crypto.verifyHash('[email protected]', emailHash);
// true — constant-time comparison prevents timing attacks
const tampered = crypto.verifyHash('[email protected]', emailHash);
// falseencryptAndHash (recommended for PII columns)
Dual-column storage pattern — encrypted value for confidentiality, hash for indexed lookups:
const { encrypted, hash } = crypto.encryptAndHash('[email protected]', EncryptionKey.PII);
// Store `encrypted` (EncryptedValue) in the encrypted column
// Store `hash` (string) in the `*Hash` index column
// Later:
const decrypted = crypto.decrypt(encrypted); // '[email protected]'
const match = crypto.verifyHash('[email protected]', hash); // truereEncrypt (key rotation)
Decrypt and re-encrypt at the current key version, optionally under a different key name:
// Re-encrypt under the current version (same key name)
const refreshed = crypto.reEncrypt(encrypted);
// refreshed.version === crypto's currentVersion
// Re-encrypt under a different key
const moved = crypto.reEncrypt(encrypted, EncryptionKey.BANK_DATA);
// moved.keyName === 'bank_data'See Key Rotation Guide for the full rotation workflow.
Bulk Operations
Encrypt or decrypt multiple fields of an object in a single call. Only the
fields listed in the fieldMap are transformed; all other fields pass through
unchanged. The input object is never mutated.
import { SecureCrypto, EncryptionKey } from '@cobranza-apps/crypto';
import type { BulkFieldMap } from '@cobranza-apps/crypto';
interface Customer {
email: string;
fullName: string;
id: number;
}
const fieldMap: BulkFieldMap<Customer> = {
email: EncryptionKey.PII,
fullName: EncryptionKey.PII,
};
// Encrypt every mapped string field — returns a shallow clone
const customer = { email: '[email protected]', fullName: 'Ana', id: 42 };
const encrypted = crypto.encryptObject(customer, fieldMap);
// encrypted.email is an EncryptedValue
// encrypted.fullName is an EncryptedValue
// encrypted.id === 42
// Decrypt every mapped EncryptedValue field — returns a shallow clone
const plaintext = crypto.decryptObject(encrypted, fieldMap);
// plaintext.email === '[email protected]'
// plaintext.fullName === 'Ana'Tip:
encryptObject/decryptObjectare ideal for DTO-to-entity mapping in NestJS services. Pair with@IsEncryptedField()from@cobranza-apps/entitiesfor end-to-end type safety.
Cached Decryptor
Wrap this SecureCrypto instance with a TTL-bounded in-memory cache to avoid
repeated AES-256-GCM decryption of hot records. Cache hits return the stored
plaintext; misses delegate to decrypt.
// Build a cached decryptor with a 30-second TTL (default: 60 s)
const cached = crypto.withCache({ ttlMs: 30_000 });
const a = cached.decrypt(encrypted); // cache miss — delegates to SecureCrypto
const b = cached.decrypt(encrypted); // cache hit — returns cached plaintext
cached.size(); // number of cached entries
cached.clear(); // invalidate all entries (call after key rotation)Security note: Caching plaintext is an explicit, opt-in decision. Never share a
CachedDecryptoracross users or tenants. Invalidate on key rotation. See Security Best Practices.
Decryption cache (opt-in)
Cache decrypted plaintext in-memory with a TTL to avoid repeated decryption of hot records:
import { createDecryptionCache } from '@cobranza-apps/crypto';
import type { EncryptedValue } from '@cobranza-apps/entities';
const cache = createDecryptionCache(60_000); // 60 s TTL
function getCachedDecrypt(encrypted: EncryptedValue): string {
const cached = cache.get(encrypted.encryptedData);
if (cached !== undefined) return cached;
const plaintext = crypto.decrypt(encrypted);
cache.set(encrypted.encryptedData, plaintext);
return plaintext;
}Security note: The cache is opt-in and bounded by TTL, not by a hard size limit. Callers should size TTLs to their memory budget. Never share a cache across users or tenants. Invalidate on key rotation.
Key introspection
crypto.hasKey('pii'); // true
crypto.getAvailableKeys(); // ['pii','company_pii','bank_data','notification','general']Note: Ciphertext is non-deterministic (random 12-byte IV); the
hashoutput is deterministic. See Testing Utilities for why exact ciphertext assertions are not part of test vectors.
Real-World Scenarios
- Email (PII) —
encryptAndHash('[email protected]', EncryptionKey.PII)for dual-column storage + lookup-by-hash. - Tax ID (Company PII) —
encryptAndHash('RFC-ABCD123456', EncryptionKey.COMPANY_PII)with dedup viahash+verifyHash. - Bank description (Bank Data) —
encrypt('Payment for invoice...', EncryptionKey.BANK_DATA)encrypt-only, decrypt on read.
See Real-World Scenarios for full code examples with the dual-column pattern and lookup-by-hash.
API Summary
| Method | Parameters | Returns | Description | Status |
|--------|-----------|---------|-------------|--------|
| constructor | config: CryptoConfig | SecureCrypto | Validates and stores config | functional |
| encrypt | plaintext: string, keyName: EncryptionKey | EncryptedValue | Encrypts a string using AES-256-GCM with HKDF-derived key | functional |
| decrypt | data: EncryptedValue | string | Decrypts an EncryptedValue, supporting any version with an available key | functional |
| hash | plaintext: string | string | Produces a deterministic HMAC-SHA256 hash | functional |
| verifyHash | plaintext: string, hash: string | boolean | Constant-time hash verification | functional |
| encryptAndHash | plaintext: string, keyName: EncryptionKey | { encrypted: EncryptedValue, hash: string } | Combined encryption + hashing for indexed PII fields | functional |
| reEncrypt | encrypted: EncryptedValue, targetKeyName?: EncryptionKey \| string | EncryptedValue | Decrypts and re-encrypts at the current version, optionally under a new key | functional |
| encryptObject | obj: T, fieldMap: BulkFieldMap<T> | T | Encrypts every string field listed in fieldMap; returns a shallow clone | functional |
| decryptObject | obj: T, fieldMap: BulkFieldMap<T> | T | Decrypts every EncryptedValue field listed in fieldMap; returns a shallow clone | functional |
| withCache | options?: { ttlMs?: number } | CachedDecryptor | Returns a TTL-cached decryptor bound to this instance | functional |
| hasKey | name: string | boolean | Checks whether a key derivation config exists for the given name | functional |
| getAvailableKeys | — | string[] | Returns all configured key names | functional |
Cache Utilities
| Export | Kind | Description |
| --- | --- | --- |
| TtlCache | class | Generic in-memory TTL cache with lazy eviction |
| createDecryptionCache | function | Factory for a TtlCache<string, string> keyed by encrypted payload |
| DecryptionCache | type | Alias for TtlCache<string, string> |
| createDecryptionCacheWrapper | function | SecureCrypto-aware cache-through decrypt wrapper |
| CachedDecryptor | interface | Cache-through decryptor returned by withCache |
| DecryptionCacheOptions | interface | Options for createDecryptionCacheWrapper |
| SecureCryptoDecryptor | interface | Minimal decryptor contract accepted by the wrapper |
| BulkFieldMap | type | Per-field key mapping for encryptObject / decryptObject |
| AuditLogger | interface | Optional observability hooks (onEncrypt, onDecrypt) invoked after successful crypto operations |
For the full interface contract, see brief.md §4.
NestJS Integration Guide
The library ships a built-in CryptoModule and CryptoService at the @cobranza-apps/crypto/nestjs subpath. Both synchronous (forRoot) and asynchronous (forRootAsync with ConfigService) registration are available:
import { CryptoModule } from '@cobranza-apps/crypto/nestjs';
import { EncryptionKey } from '@cobranza-apps/crypto';
// Sync:
CryptoModule.forRoot({ masterKey: '...', hashSalt: '...', currentVersion: 1, defaultKeyName: EncryptionKey.PII });
// Async with ConfigService:
CryptoModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ /* ... */ }),
});See the full How to Configure in NestJS guide for CryptoModule registration, interceptor pattern, DTO integration, rotation, testing, and deployment.
EncryptionKey is from this library; @IsEncryptedField() and EncryptedValue are from @cobranza-apps/entities.
For a complete end-to-end example (module + DTO + service + subscriber + test) see Full NestJS Integration Example, including Bulk Multi-Field Encryption with encryptObject / decryptObject.
Security Best Practices
The following practices are critical when handling sensitive data. Follow them to avoid data leaks, key compromise, and compliance violations.
Key Storage
- Load
masterKeyandhashSaltfrom a secrets manager / vault / KMS at boot viaConfigService; never hardcode or commit them. - Keep
masterKeyandhashSaltas distinct secrets with independent rotation lifecycles. - Restrict secret access to the service identity; never expose via logs, traces, error responses, or client payloads.
- Use separate secrets per environment (dev/staging/prod). The testing subpath uses fixed zero keys and MUST NOT be used in production.
Logging Rules
- Never log plaintext, decrypted values, the master key, derived keys, the hash salt, IVs, or the full
EncryptedValue.encryptedDatapayload. - Log only non-sensitive error messages — the library throws closed errors without secret material.
- When logging request/response bodies that may contain
EncryptedValuefields, redact or omit those fields. keyNameandversionare acceptable in internal operational telemetry but should not appear in user-facing logs.
General Rules
- Fail closed: errors are thrown; never returned as partial results.
- IV is 12 random bytes per encryption; never reused.
- Hash verification uses constant-time comparison (
crypto.timingSafeEqual). - Use
encryptAndHash(nothashalone) when the field also needs confidentiality. - Follow the full Key Rotation Guide and the NestJS deployment guidance.
Security Checklist
Quick production-readiness checklist:
- [ ] Never hardcode or commit secrets — load
masterKeyandhashSaltfrom a vault / KMS. - [ ] Never expose keys, plaintext, IVs, or
encryptedDatain logs or error responses. - [ ] Use
encryptAndHash(nothashalone) when the field needs confidentiality. - [ ] Use constant-time
verifyHashfor hash comparisons, never===. - [ ] Run a background
reEncryptjob after incrementingcurrentVersion. - [ ] Isolate the decryption cache per request/process; never share across users.
Full checklist: Security Checklist.
Observability & Auditing
Pass an optional AuditLogger via CryptoConfig.auditLogger to receive
non-sensitive metadata after every successful encrypt or decrypt call.
Hooks receive only keyName and version — never plaintext,
ciphertext, keys, IVs, or any other sensitive material. This is enforced at
the type level: the AuditLogger interface signatures have no parameter
capable of carrying sensitive payload data.
A logger that throws is silently swallowed so a misbehaving implementation can never break a crypto operation.
import { SecureCrypto, EncryptionKey } from '@cobranza-apps/crypto';
import type { AuditLogger, CryptoConfig } from '@cobranza-apps/crypto';
const auditLogger: AuditLogger = {
onEncrypt(keyName, version) {
metrics.increment('crypto.encrypt', { keyName, version });
},
onDecrypt(keyName, version) {
metrics.increment('crypto.decrypt', { keyName, version });
},
};
const config: CryptoConfig = {
masterKey: process.env.COBRANZA_CRYPTO_MASTER_KEY!,
hashSalt: process.env.COBRANZA_CRYPTO_HASH_SALT!,
currentVersion: 1,
defaultKeyName: EncryptionKey.PII,
auditLogger, // <-- optional observability hooks
};
const crypto = new SecureCrypto(config);Security note: Audit hooks are the only extension point that crosses the
SecureCryptoboundary. The contract guarantees that no sensitive data (plaintext, ciphertext, derived keys, IVs, auth tags) is ever passed to consumer code. LogkeyNameandversionfreely in internal telemetry, but avoid emitting them in user-facing responses.
Key Rotation Guide
This library rotates derived keys by incrementing currentVersion (single masterKey; the version is part of the HKDF info, so a new version yields a new derived key from the same master key). Historical records stay decryptable because each EncryptedValue carries its version.
- Increment
COBRANZA_CRYPTO_KEY_VERSION(e.g.1->2). No new master key. - Deploy — new encryptions carry
version: 2; existing records keep their originalversion. - Run an external background job to migrate old records:
crypto.reEncrypt(oldEncrypted)decrypts at the payload's version and re-encrypts atcurrentVersionin one call. See the reEncrypt example. - Verify all records migrated (no records left on the old
version). - Hash columns need no migration — hashes are keyed by
hashSalt, not version. - Clear the decryption cache if in use (
cache.clear()).
Rotating the actual master-key material is a larger, out-of-library migration (decrypt-all with the old key, re-encrypt-all with the new key). See the full Key Rotation Guide.
Performance Considerations
- Internal HKDF cache: caches derived per-category keys in memory keyed by
${keyName}:v${version}. Repeatedencrypt/decryptcalls with the same key name and version do not re-derive. - Plaintext caching: may cache decrypted values in-memory with a short TTL using
createDecryptionCache(see Decryption cache). The cache is opt-in and bounded by TTL, not by a hard size limit. Callers should size TTLs to their memory budget. Isolate per request or process — never shared across users or tenants. Invalidate on key rotation. - Hashing performance:
hash/verifyHashare deterministic and idempotent — safe to call repeatedly without caching. - Bulk re-encryption: during key rotation, run re-encryption as an external background job with batching / rate-limiting.
- Ciphertext overhead: each value adds
IV(12) + authTag(16) = 28 bytesbefore Base64 (~33% inflation). Size columns accordingly. - Synchronous cost: crypto calls block the event loop; negligible for PII-size fields (<1 KB), offload large payloads to a worker thread if latency-sensitive.
Full guide: Performance Considerations.
Testing
Consumer testing
Jest consumers can use the testing subpath:
import { getTestCrypto, SecureCryptoTestModule } from '@cobranza-apps/crypto/testing';
import { EncryptionKey } from '@cobranza-apps/crypto';
const crypto = getTestCrypto();
const { encrypted, hash } = crypto.encryptAndHash('[email protected]', EncryptionKey.PII);Note: Ciphertext is non-deterministic (AES-256-GCM uses a random 12-byte IV). The
expectedEncryptedShapefield on each vector provides a deterministic structural assertion (algorithm,keyName,version,encryptedDataByteLength). Exact ciphertext correctness is verified via encrypt-decrypt roundtrip. See Testing Utilities for details.
getTestCrypto()returns aSecureCryptowith fixed, deterministic keys -- safe to publish; never usable in production.test-vectors.tsprovides 11 deterministic vectors with realexpectedHashliterals andexpectedEncryptedShapefor structural assertions.SecureCryptoTestModuleis a NestJS-friendly provider config (spreadable intoTest.createTestingModule); it does not require@nestjs/testingas a dependency of this library.
Library test suite
npm test
npm run test:watchThe library's own test suite uses Jest + ts-jest.
Development
npm install
npm run build # tsc -> dist/
npm run lint # eslint --ext .ts
npm test # jestPackage layout
src/
index.ts
config.ts
audit.ts
crypto.service.ts
crypto.service.audit.ts
crypto.service.bulk-guards.ts
crypto.service.encryption.ts
crypto.service.facade-guards.ts
crypto.service.guards.ts
crypto.service.hashing.ts
crypto.service.keys.ts
crypto.service.validation.ts
hkdf.ts
hkdf.types.ts
utils.ts
testing/
index.ts
test-vectors.ts
encrypted-shape.ts
nestjs/
crypto-config.interface.ts
crypto.module.ts
crypto.service.ts
index.ts
tests/
dist/
docs/Guides
- Getting Started — Install, generate keys, and run your first encrypt/decrypt/hash.
- Full NestJS Integration Example — End-to-end module + DTO + service + subscriber + test.
- Security Checklist — Production security checklist (key management, logging, caching, rotation).
- Security Guide — Consolidated guide: key storage, rotation summary, common pitfalls, buffer hygiene, runtime validation.
- Key Rotation Guide — Version-based rotation and reEncrypt migration.
- Performance Considerations — HKDF cache, ciphertext overhead, sync cost, GCM limits.
- Real-World Scenarios — taxId, email, and bank description patterns.
- How to Set Up Git — Configure Git credentials for GitHub.
- How to Write TODO Files — Task assignment formats for AI agents.
- Testing Utilities — Importing and using the testing subpath (Jest + NestJS).
- How to Configure in NestJS — Built-in
CryptoModule(forRoot/forRootAsync),CryptoService, interceptor pattern, DTO integration, testing, and deployment. - Documentation Index — Full list of available documentation.
- DTO / Decorator Integration — Pipes, interceptors, and TypeORM subscribers for automatic plain-string → EncryptedValue + hash conversion.
License
Released to the public domain under The Unlicense. See LICENSE for details.
AI agents: read
AGENTS.mdand follow the Critical Workflow before contributing. Project info lives in.agent/project-info/.
