@vonpay/crypto
v0.1.1
Published
AES-256-GCM encrypt/decrypt with key versioning + retired-key fallback, plus HKDF-SHA-256 per-merchant key derivation
Maintainers
Readme
@vonpay/crypto
Cryptographic primitives used by Vonpay services to encrypt secrets at rest. AES-256-GCM encrypt / decrypt with retired-key fallback, plus HKDF-SHA-256 per-merchant key derivation under a versioned master-key registry.
Internal — the surface is designed for Vonpay use cases (per-merchant credential encryption for downstream services) but the building blocks are standard RFC primitives with no Vonpay-specific business logic.
Why
Vonpay services need to encrypt PII and provider credentials at rest. This package centralises the primitive so every service uses the same AES-256-GCM construction and the same key-rotation discipline. It is intentionally env-agnostic at the crypto core — encrypt / decrypt never read process.env; callers pass parsed keys in. The per-merchant-key subpath does ship a small env-reading helper (loadRegistryFromEnv) for the per-merchant derivation flow, scoped to a single well-known env-var prefix that callers can override.
Install
npm install @vonpay/cryptoInside the vonpay monorepo, depend on it as a workspace package:
{
"dependencies": {
"@vonpay/crypto": "workspace:*"
}
}Requires Node 20+. ESM only.
Root API — AES-256-GCM encrypt/decrypt
import { encrypt, decrypt, parseHexKey } from "@vonpay/crypto";
// Caller owns env loading.
const activeKey = parseHexKey(process.env.MY_ENCRYPTION_KEY!);
const retiredKeys = (process.env.MY_ENCRYPTION_KEYS_RETIRED ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseHexKey);
const encoded = encrypt("[email protected]", activeKey);
// Try active first, then retired keys (allows in-place key rotation).
const plaintext = decrypt(encoded, [activeKey, ...retiredKeys]);Wire format
base64( iv (12 bytes) || tag (16 bytes) || ciphertext ). Always GCM, always 12-byte IV, always 16-byte tag.
Direct-key rotation (single-key consumers)
For services with a single master key and no per-merchant derivation (the original 0.1.0 model):
- Generate a new key:
openssl rand -hex 32 - Move the current key value into your
MY_ENCRYPTION_KEYS_RETIREDenv (comma-separated). - Set
MY_ENCRYPTION_KEYto the new key. - Restart the service. New writes use the new key; old rows continue to decrypt via the retired key.
- Bump your service's key-version tag (e.g.
v1→v2) on the same deploy so rows tag the new key. Retired keys can be dropped after every row has been re-encrypted via a backfill.
Subpath API — per-merchant key derivation
import { encrypt, decrypt } from "@vonpay/crypto";
import {
deriveMerchantKey,
loadRegistryFromEnv,
} from "@vonpay/crypto/per-merchant-key";
// Load master keys once at boot. Default prefix VONPAY_MASTER_KEY_V*;
// pass a custom prefix to namespace per product.
const registry = loadRegistryFromEnv();
// At write time: derive under the current version, persist the version next to the ciphertext.
const merchantId = "1f1c8c34-7a09-4b7a-9a3a-1d2e3f405060";
const { key, version } = deriveMerchantKey({
registry,
merchantId,
purpose: "access-token",
});
const ciphertext = encrypt("shpat_…", key);
// INSERT ... (encrypted_access_token, encryption_key_version) VALUES (ciphertext, version)
// At read time: derive under the stored version.
const { key: readKey } = deriveMerchantKey({
registry,
merchantId,
purpose: "access-token",
version: storedRow.encryption_key_version,
});
const plaintext = decrypt(storedRow.encrypted_access_token, [readKey]);HKDF parameter shape
Identical for every consumer:
- IKM — the master key for the requested version (
VONPAY_MASTER_KEY_V<N>by default; consumers can pass a custom prefix toloadRegistryFromEnv) - salt —
SHA-256(merchantId)(deterministic per merchant, non-secret) - info —
vonpay-merchant-${purpose}-v${version}(the embeddedversionguarantees v1 and v2 keys are cryptographically distinct even for the same(merchantId, purpose)) - L — 32 bytes (AES-256 key length)
purpose is a free-form string validated against /^[a-z][a-z0-9-]{0,31}$/ — lowercase, starts with a letter, hyphen-separated, ≤32 chars. Currently defined purposes are "access-token" and "webhook-secret"; downstream consumers add their own without a breaking change.
Canonical purposes — to avoid cryptographic-distinct-key collisions across call sites that meant to derive the same key, treat the purpose string as a coordinated identifier. Misspelling "webhook-secret" as "wh-secret" derives a different (irrecoverable) key. Confine purpose strings to a small, reviewed set per consumer.
N-version master-key rotation
The registry holds every version that is currently loaded — not just two. To rotate:
- Generate a new key:
openssl rand -hex 32 - Set the new key as
VONPAY_MASTER_KEY_V<N+1>(where<N>is the current highest version, using the default prefix; substitute a custom prefix where you set one). Keep_V<N>set. - Restart the service. The registry now contains both versions;
currentVersionautomatically becomesN+1. New writes derive underN+1; existing rows continue to decrypt via their stored version. - (At leisure) Run a backfill that reads each row, decrypts under its stored version, re-encrypts under the current key, and updates
encryption_key_version. Stop the backfill worker before counting — an in-flight batch can leave rows referencing the old version even after the count query returns zero. Verify zero on two consecutive reads separated by at least one retry cycle. - After the backfill confirms zero rows reference
<N>, unsetVONPAY_MASTER_KEY_V<N>and restart. The retired version is no longer loaded.
No _NEXT flag, no "rotation window" mode — adding a higher-numbered env var IS the rotation signal.
License
MIT
