@systemix/cipher

Authenticated AES-256-GCM encryption and decryption built on Web Crypto. Versioned envelope with key ids and key rotation. Requires Node.js 24.7+. For encrypting sensitive data at rest — database fields, config secrets, tokens.

Installation

ts
pnpm add @systemix/cipher

Basic Usage

ts
import {
  generateKeyBytes,
  encryptString,
  decryptString,
} from '@systemix/cipher';

const key = generateKeyBytes(); // 32 random bytes (AES-256)

const payload = await encryptString('sensitive data', key);
// → "U1gBAC..." (base64url of the envelope)

await decryptString(payload, key); // → "sensitive data"

The payload is a versioned envelope — SX | version | keyId | IV | ciphertext + tag. It's one value to store, the IV is random per call, and the header is authenticated so it can't be altered undetected. Payloads from 0.1.x still decrypt.

Tamper Detection

AES-GCM is authenticated: any modification to the ciphertext (or wrong key) throws DecryptionError instead of returning garbage.

ts
import { DecryptionError } from '@systemix/cipher';

try {
  await decryptString(payload, wrongKey);
} catch (error) {
  if (error instanceof DecryptionError) {
    // wrong key, tampered data, or malformed payload
  }
}

Examples

Additional authenticated data

Bind ciphertext to a context (user ID, record type). Decryption fails unless the exact same AAD is supplied:

ts
const payload = await encrypt(recordBytes, key, {
  additionalData: new TextEncoder().encode('user:42'),
});

Key derivation from a password

ts
import { deriveKeyFromPassword, generateSalt } from '@systemix/cipher';

const salt = generateSalt(); // store alongside the ciphertext
const key = await deriveKeyFromPassword('master password', salt);

Key rotation

ts
import { createKeyring } from '@systemix/cipher';

const ring = createKeyring({
  keys: { k2: newKey, k1: oldKey },
  current: 'k2',
});

const blob = await ring.encryptString('secret'); // stamped "k2"
await ring.decryptString(oldBlob); // envelope names "k1" → still works

if (ring.needsRotation(oldBlob)) {
  save(await ring.encryptString(await ring.decryptString(oldBlob)));
}

Random 96-bit IVs are safe for roughly 2^32 messages per key; rotate well before that.

Key Management

FunctionDescription
generateKeyBytes(length?)Random AES key — 16, 24, or 32 bytes
deriveKeyFromPassword(password, salt, iterations?)PBKDF2-SHA256 → 32-byte AES key
generateSalt(length?)Random salt (min 8 bytes)

Try it

Open the Cipher demo to encrypt, decrypt, and tamper with ciphertexts interactively.