@molecule/api-encryption-aes

Provider bond · encryption · API (Node) · v1.1.1 · Apache-2.0

AES-256-GCM encryption provider for molecule.dev — authenticated encryption, SHA-256 hashing, and key rotation

npm install @molecule/api-encryption-aes

npm · Source on GitHub · Implements @molecule/api-encryption

How it works

@molecule/api-encryption-aes is a provider bond on the API (Node) side: it implements the encryption core interface (@molecule/api-encryption) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { setProvider } from '@molecule/api-encryption'
import { provider } from '@molecule/api-encryption-aes'

// Wire the provider at startup (reads ENCRYPTION_KEY from env)
setProvider(provider)

// Or create with explicit config
import { createProvider } from '@molecule/api-encryption-aes'
const customProvider = createProvider({ key: 'your-64-char-hex-key' })
setProvider(customProvider)

// Stream a large file through chunked AES-256-GCM (1 MiB chunks by default)
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
await pipeline(
  createReadStream('dump.sql'),
  customProvider.encryptStream({ context: 'backup:42', chunkBytes: 4 * 1024 * 1024 }),
  createWriteStream('dump.sql.enc'),
)
await pipeline(
  createReadStream('dump.sql.enc'),
  customProvider.decryptStream({ context: 'backup:42' }),
  createWriteStream('dump.sql'),
)

Works with: @molecule/api-encryption, @molecule/api-secrets

Secrets: ENCRYPTION_KEY

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

AES-256-GCM encryption provider for molecule.dev.

Uses Node.js built-in crypto for AES-256-GCM authenticated encryption, SHA-256 hashing, and timing-safe verification. Supports key rotation with versioned ciphertext format.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { setProvider } from '@molecule/api-encryption'
import { provider } from '@molecule/api-encryption-aes'

// Wire the provider at startup (reads ENCRYPTION_KEY from env)
setProvider(provider)

// Or create with explicit config
import { createProvider } from '@molecule/api-encryption-aes'
const customProvider = createProvider({ key: 'your-64-char-hex-key' })
setProvider(customProvider)

// Stream a large file through chunked AES-256-GCM (1 MiB chunks by default)
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
await pipeline(
  createReadStream('dump.sql'),
  customProvider.encryptStream({ context: 'backup:42', chunkBytes: 4 * 1024 * 1024 }),
  createWriteStream('dump.sql.enc'),
)
await pipeline(
  createReadStream('dump.sql.enc'),
  customProvider.decryptStream({ context: 'backup:42' }),
  createWriteStream('dump.sql'),
)

Type

provider

Installation

npm install @molecule/api-encryption-aes @molecule/api-encryption @molecule/api-secrets

API

Interfaces

PriorKey

A historical (pre-rotation) key, retained so ciphertext encrypted under it remains decryptable.

Ciphertext is tagged with the v{version} of the key that produced it; the provider selects the matching key from its keyring at decrypt time. Seed the keyring with the app's prior keys (from a secret store) so rotations survive a process restart — see AesConfig.priorKeys.

interface PriorKey {
  /** The key version tag this key decrypts (matches the `v{n}` ciphertext prefix). */
  version: number

  /** The 256-bit key, hex-encoded (64 hex characters). */
  key: string
}

AesConfig

Configuration options for the AES-256-GCM encryption provider.

interface AesConfig {
  /** The current 256-bit encryption key, hex-encoded (64 hex characters). */
  key: string

  /**
   * Version of the current `key`. Ciphertext produced by `encrypt()` is tagged
   * with this version; `decrypt()` selects the key to use from the `v{n}` tag,
   * so it must be stable for a given key. `rotateKey()` advances it.
   *
   * @default 1
   */
  keyVersion?: number

  /**
   * Prior keys to seed the keyring with, so ciphertext encrypted before a
   * rotation still decrypts after this provider is (re)constructed — e.g. on a
   * process restart, when the in-memory rotation state would otherwise be lost.
   * Each entry maps a historical `v{n}` version to its key. The current `key`
   * (at `AesConfig.keyVersion`) always wins over any prior entry sharing
   * its version.
   *
   * @default []
   */
  priorKeys?: PriorKey[]

  /**
   * Default plaintext bytes per chunk for `encryptStream()` (4 KiB to 16 MiB).
   * A per-call `chunkBytes` overrides it. Decryption reads the size from the
   * stream header, so this never needs to match at decrypt time.
   *
   * @default 1048576 (1 MiB)
   */
  streamChunkBytes?: number
}

AesEncryptStreamOptions

Options for the AES bond's encryptStream().

interface AesEncryptStreamOptions extends EncryptStreamOptions {
  /**
   * Plaintext bytes per chunk (4 KiB to 16 MiB). Larger chunks mean less
   * overhead (16 bytes per chunk) but more memory per chunk on both sides.
   *
   * @default AesConfig.streamChunkBytes, else 1048576 (1 MiB)
   */
  chunkBytes?: number
}

AesEncryptionProvider

AES-256-GCM encryption provider.

Fully satisfies the core EncryptionProvider contract, so it bonds anywhere an EncryptionProvider is expected. It additionally exposes AesEncryptionProvider.pruneKeyVersions to explicitly retire old keys once their ciphertext has been re-encrypted — rotation alone never drops a key, so it can never orphan data.

interface AesEncryptionProvider extends EncryptionProvider {
  /**
   * Retires key versions from the in-memory keyring. Call this only AFTER every
   * ciphertext encrypted under those versions has been re-encrypted under the
   * current key — a pruned version can no longer decrypt its ciphertext.
   *
   * The current key version is ALWAYS retained (it cannot be pruned). By
   * default every non-current version is removed; pass `keep` to retain
   * specific older versions during a staged migration.
   *
   * @param keep - Old versions to retain in addition to the current one.
   * @returns The versions that were removed.
   */
  pruneKeyVersions(keep?: number[]): number[]

  /**
   * Encrypts a byte stream under the CURRENT key in the core's
   * `mol-aead-chunked-v2` framing (chunked AES-256-GCM).
   *
   * @param options - AAD context and chunk size.
   * @returns A Transform: plaintext in, ciphertext out.
   * @throws {Error} `EncryptionStreamError` (code `internal`) on an out-of-range chunk size.
   */
  encryptStream(options?: AesEncryptStreamOptions): Transform

  /**
   * Authenticates and decrypts a stream from `encryptStream`, picking
   * the key by the header's version from the keyring. Plaintext is emitted
   * only after each chunk's tag verifies; the stream errors with an
   * `EncryptionStreamError` whose `code` is `auth` (tampering, reordering,
   * a wrong context), `unknown-key-version`, `unknown-key` (a different key
   * under the same version), `bad-header` or `truncated`.
   *
   * @param options - The AAD context used to encrypt.
   * @returns A Transform: ciphertext in, plaintext out.
   */
  decryptStream(options?: EncryptStreamOptions): Transform
}

Functions

createProvider(config)

Creates an AES-256-GCM encryption provider.

Rotation is transition-safe: keys are held in a version-keyed keyring. encrypt() tags each ciphertext with the current key version, decrypt() selects the key named by that tag, and rotateKey() ADDS the new key while keeping prior keys — so ciphertext from before a rotation still decrypts. Retire old keys explicitly (once their data is re-encrypted) via pruneKeyVersions().

function createProvider(config: AesConfig): AesEncryptionProvider
  • config — Provider configuration including the hex-encoded 256-bit key.

Returns: An AesEncryptionProvider using AES-256-GCM.

Constants

provider

Default AES-256-GCM encryption provider instance.

Lazily initializes on first property access using the ENCRYPTION_KEY environment variable (hex-encoded 256-bit key). This singleton starts a fresh keyring at version 1 on each process, so a rotation performed in a prior process is NOT restored here — for rotation that survives restarts, construct with createProvider({ key, priorKeys }) seeding the historical keys from your secret store.

const provider: AesEncryptionProvider

encryptionAesSecretDefinitions

Secret definitions required by the AES encryption bond.

const encryptionAesSecretDefinitions: SecretDefinition[]

Core Interface

Implements @molecule/api-encryption interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-encryption'
import { provider } from '@molecule/api-encryption-aes'

export function setupEncryptionAes(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-encryption ^1.1.0
  • @molecule/api-secrets ^1.0.1

Environment Variables

  • ENCRYPTION_KEY (required) — AES-256 encryption key
    • Auto-generated at scaffold — no manual setup.

Runtime Dependencies

  • @molecule/api-encryption

  • @molecule/api-secrets

  • rotateKey() is transition-safe — it never orphans data. Keys live in a version-keyed keyring: encrypt() tags each ciphertext with the current v{n}, decrypt() reads that tag and selects the matching key, and rotateKey(oldKey, newKey) ADDS newKey at the next version while RETAINING the prior key(s). So ciphertext written before a rotation still decrypts afterward (the core contract's "previously encrypted data can still be decrypted during a transition period"). Once you have re-encrypted the old ciphertext under the new key, retire the old keys explicitly with pruneKeyVersions() — rotation alone deliberately keeps them.

  • A ciphertext whose v{n} version is not in the keyring (unknown/pruned key) fails cleanly with a descriptive error — never a silent wrong decrypt.

  • Rotation state is per-process and in-memory: the lazy provider singleton starts a fresh keyring at version 1 from ENCRYPTION_KEY on each process, so a rotation done in a prior process is not restored. For rotation that survives restarts, build with createProvider({ key, priorKeys }), seeding the historical { version, key } entries from your secret store.

  • createProvider refuses a keyVersion outside 0–65535 (the stream header's range) and a priorKeys entry at the current version, at construction. verify() answers false (never throws) for a candidate hash of any shape.

  • hash()/verify() are plain unsalted SHA-256 — integrity checks only. NEVER use them for passwords; use @molecule/api-password with a bond like @molecule/api-password-bcrypt.

  • encrypt(plaintext, context): the optional context is GCM AAD — the SAME context string must be supplied to decrypt() or authentication fails.

  • encryptStream() / decryptStream() implement the core's mol-aead-chunked-v2 framing with AES-256-GCM. Header (54 bytes) = MOLAEAD2 magic (8), key version (uint16 BE), chunk size (uint32 BE), 32-byte random salt, 8-byte key id (first 8 bytes of HMAC-SHA256(key, mol-aead-chunked-v2/kid)). Each stream derives its own AES-256 key and 8-byte nonce prefix with HKDF-SHA256(key, salt, mol-aead-chunked-v2, 40 bytes) — the configured key (often shared with field encryption) never seals a chunk directly, and nonces cannot repeat across streams. Chunks of up to chunkBytes plaintext are sealed with nonce prefix || counter (uint32 BE, from 0) and AAD header || context || counter || final (every chunk) and followed by their 16-byte tag. Every chunk but the last is exactly chunkBytes; the last is always written and always shorter (empty when the input is an exact multiple).

  • Chunk size: chunkBytes per call, else streamChunkBytes from createProvider(), else 1 MiB; allowed range 4 KiB to 16 MiB. Decryption reads it from the header. Memory use is about one chunk.

  • Streams encrypt under the CURRENT key version (written in the header); decryptStream() picks the key by that version from the keyring, so streams written before rotateKey() (or seeded via priorKeys) still decrypt.

  • decryptStream() emits a chunk's plaintext only after its tag verifies, and destroys itself with an EncryptionStreamError whose code says why: bad-header (wrong magic, invalid chunk size, short header), unknown-key-version (version not in the keyring — pruned or never seeded), unknown-key (the keyring holds that version but it is a different key: a mistyped or replaced ENCRYPTION_KEY, NOT tampering), auth (a flipped bit, reordered chunks, an edited or swapped header, a wrong context), truncated (ended at a chunk boundary before the final chunk — possibly a dropped connection). Write decrypted output to a temp location and use it only after the pipeline resolves.