AES-256-GCM encrypt/decrypt for storing secrets at rest. Pure crypto, zero dependencies, Node 18+.
npm install @hameddk/secret-storageimport { encrypt, decrypt } from '@hameddk/secret-storage';
import { randomBytes } from 'node:crypto';
// Generate a key once and store it somewhere safe
// (env var, secrets manager, KMS — your call)
const key = randomBytes(32).toString('hex');
const ciphertext = encrypt('hello world', key);
// "v1:4b2f9c...:9a1e7d...:c8d3a2..."
const plaintext = decrypt(ciphertext, key);
// "hello world"The key parameter can be either a 32-byte Buffer or a 64-character hex string. Either way, you provide the key — this library never reads from process.env or the filesystem.
| Param | Type | Description |
|---|---|---|
plaintext |
string |
UTF-8 string to encrypt. Empty string is allowed. |
key |
Buffer | string |
32-byte Buffer or 64-char hex string. |
Returns ciphertext in the format <iv-hex>:<tag-hex>:<data-hex>.
Throws:
InvalidKeyError— key is not 32 bytes / not valid hex / wrong length.TypeError— plaintext is not a string.
| Param | Type | Description |
|---|---|---|
ciphertext |
string |
Output from a prior encrypt() call. |
key |
Buffer | string |
Same key used for encryption. |
Returns the original UTF-8 plaintext.
Throws:
InvalidKeyError— key is malformed.MalformedCiphertextError— wrong number of parts, non-hex bytes.DecryptionError— auth tag mismatch (wrong key, or ciphertext was tampered with).
import {
InvalidKeyError,
MalformedCiphertextError,
DecryptionError,
} from '@hameddk/secret-storage';
try {
decrypt(ct, key);
} catch (err) {
if (err instanceof DecryptionError) {
// Wrong key or tampered data
} else if (err instanceof MalformedCiphertextError) {
// Input wasn't a valid ciphertext string
}
}encrypt() always emits the versioned format:
v1:<iv-hex>:<tag-hex>:<ciphertext-hex>
| Part | Bytes | Hex chars | Notes |
|---|---|---|---|
v1 |
— | 2 | Format version literal — currently the only one |
| IV | 12 | 24 | Random per encryption (NIST SP 800-38D recommended length) |
| Auth tag | 16 | 32 | GCM authentication tag |
| Ciphertext | variable | 2× plaintext bytes | Encrypted body (may be empty) |
The format is documented as a stable contract. You can decrypt manually with Node crypto if you ever need to migrate away from this package.
For backward compatibility, decrypt() also accepts the unversioned three-part format:
<iv-hex>:<tag-hex>:<ciphertext-hex>
This is what older codebases (including the project this module was extracted from) produced before the version prefix was introduced. IV length is parsed from the stored hex, so legacy ciphertexts with 16-byte IVs decrypt correctly. encrypt() never produces this format — only decrypt() accepts it as input.
If a future version of the format is introduced, decrypt() will route on the v<N>: prefix; the unversioned legacy form will continue to map to AES-256-GCM as it does today.
- Bring your own key. This library never generates, persists, rotates, or fetches keys. That's deliberately out of scope. Use
crypto.randomBytes(32)or a KMS to produce one. - AES-256-GCM provides confidentiality and integrity. Tampering with the ciphertext or auth tag causes
decrypt()to throwDecryptionError. - Random IV per call. Reusing an IV with the same key catastrophically breaks GCM. This library generates a fresh 12-byte IV via
crypto.randomBytes()on everyencrypt()call. - No key rotation built in. If you need to rotate a key, decrypt-then-re-encrypt at your storage layer.
- Not for streaming or large files. This is a memory-only API — both plaintext and ciphertext fit in a string.
MIT — see LICENSE.