Envelope Encryption with AWS KMS in Node.js, Part Three: Encrypting and Decrypting Files

Karol Moroz on

This is the third installment of a series of articles discussing a cryptographic technique called envelope encryption. A more detailed description of this technique is provided in part one of this series.

In part two, we introduced the AWS KMS API, created an IAM user, and set up local credentials. In this article, we are going to put it all together by writing a CLI tool to encrypt and decrypt files at will.

We will continue working on the project we created in part two. You can find the source code on my GitHub at moroz/envelope-encryption-node, branch tutorial/part-2. The code is MIT-licensed.

In this series, I use mise-en-place to install and manage tool versions, so make sure you have it installed and configured if you intend to follow along:

$ mise version --json
{
  "version": "2026.6.11 macos-arm64 (2026-06-16)",
  "latest": "2026.6.11",
  "os": "macos",
  "arch": "arm64",
  "build_time": "2026-06-16 23:02:34 +00:00"
}

File format description

In the previous parts, we have discussed ways to encrypt a file using a symmetric key. However, we have not yet discussed the exact binary format we’re going to use for data storage. Let’s quickly discuss the requirements for our bespoke format.

  1. We need to store the wrapped Data Encryption Key. Based on my experiments, the wrapped key returned by GenerateDataKey is 168 bytes for AES-128 and 184 bytes for AES-256. Although all my requests these few days have returned ciphertexts of this length, it is not guaranteed to have a constant length, as the ciphertext format is proprietary. In fact, the example response provided in the AWS KMS documentation for GenerateDataKey has a CiphertextBlob just 167 bytes long, and that’s for AES-256!

    To play it safe, we should also store…

  2. …the length of the key. Based on my experiments, the length does not usually exceed 255 bytes, so technically this could just be an 8-bit unsigned integer. That said, the response syntax documentation specifies the maximum length as 6144 bytes, Base64-encoded. That equals 4608 bytes after decoding. Either way, we need 2 bytes to safely store the length of the key. Let’s make it a 16-bit unsigned integer, big endian.

  3. An initialization vector (IV), also known as a nonce. In AES-GCM, the IV is always 12 bytes. The IV is not a secret, and can be stored in plaintext alongside the encrypted message.

  4. The ciphertext along with the authentication tag generated at the encryption stage.

  5. On top of that, it might be a good idea to also store a version marker to give ourselves space to introduce breaking changes in a later revision.

Based on the above, I came up with the following file format:

Byte 0
Version marker (0x01)
Bytes 1–2

Wrapped key length (16-bit unsigned integer, big endian). Assuming a key length of 184: 0x00 0xB8.

Bytes 3–186
Wrapped key (assuming a key length of 184).
Bytes 187–198
Initialization Vector (IV) — 12 bytes.
Bytes 199–EOF
Ciphertext and authentication tag (appended automatically at the end of the ciphertext by crypto.subtle.encrypt).

Reading the input file

For the sake of simplicity, the program only supports encrypting files coming from the standard input. In order to encrypt a whole file, we first need to load the entire file into RAM. The easiest way to read from standard input until EOF seems to be using fs.readFile. Unfortunately, only the older, callback-based module node:fs supports reading directly from standard input. In order to use the callback-based implementation with async/await, we need to promisify it. Create a new file called src/encrypt.ts:

import { promisify } from "node:util";
import { readFile } from "node:fs";

const readFilePromise = promisify(readFile);
const plaintext = await readFilePromise(process.stdin.fd);
console.log({ plaintext });

We can test it using shell pipes:

$ echo -n test | node src/encrypt.ts
{ plaintext: <Buffer 74 65 73 74> }

Let’s try it with a longer input:

$ cat pnpm-lock.yaml | node src/encrypt.ts
{
  plaintext: <Buffer 2d 2d 2d 0a 6c 6f 63 6b 66 69 6c 65 56 65 72 73 69 6f 6e 3a 20 27 39 2e 30 27 0a 0a 69 6d 70 6f 72 74 65 72 73 3a 0a 0a 20 20 2e 3a 0a 20 20 20 20 63 ... 28810 more bytes>
}

Generate a key using AWS KMS

Once we have successfully read the file contents, we can call the AWS KMS GenerateDataKey API to generate a Data Encryption Key. First, we need to inject the KMS key ID from environment variables.

This part of the configuration will be identical for both the encryptor and the decryptor programs, therefore it makes sense to put it in a shared module. Create src/config.ts:

export function mustGetenv(name: string): string {
  const value = process.env[name];
  if (!value) {
    console.error(`FATAL: Environment variable ${name} is not set!`);
    process.exit(1);
  }
  return value;
}

export const ENCRYPTION_KEY_ID = mustGetenv("ENCRYPTION_KEY_ID");

Next, import the AWS SDK and generate a Data Encryption Key:

import { GenerateDataKeyCommand, KMSClient } from "@aws-sdk/client-kms";

const kmsClient = new KMSClient();

const generateKeyResponse = await kmsClient.send(
  new GenerateDataKeyCommand({
    KeyId: ENCRYPTION_KEY_ID,
    KeySpec: "AES_256",
  }),
);

Convert the key material returned by KMS to a CryptoKey:

const encryptionKey = await crypto.subtle.importKey(
  "raw",
  generateKeyResponse.Plaintext as Uint8Array<ArrayBuffer>,
  "AES-GCM",
  false,
  ["encrypt"],
);

Encrypt the plaintext

Before we can perform the actual encryption step, we need to generate an initialization vector (IV):

const iv = crypto.getRandomValues(new Uint8Array(12));

Finally, we can encrypt the plaintext using AES-GCM:

const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, encryptionKey, plaintext);

Build a file header

Now we can start outputting data. First, let’s build a file header — a constant version marker of 0x01, followed by the length of the wrapped DEK in bytes. Allocate a Buffer just big enough to store the header:

const header = Buffer.alloc(3);
header.writeUint8(1);

// Write the length of the wrapped key as 16-bit unsigned integer,
// big endian, at offset 1
header.writeUint16BE(generateKeyResponse.CiphertextBlob!.byteLength, 1);

Let’s test that the logic works. Put everything together in src/encrypt.ts:

import { promisify } from "node:util";
import { readFile } from "node:fs";
import { GenerateDataKeyCommand, KMSClient } from "@aws-sdk/client-kms";
import { ENCRYPTION_KEY_ID } from "./config.ts";

const readFilePromise = promisify(readFile);
const plaintext = await readFilePromise(process.stdin.fd);

const kmsClient = new KMSClient();

const generateKeyResponse = await kmsClient.send(
  new GenerateDataKeyCommand({
    KeyId: ENCRYPTION_KEY_ID,
    KeySpec: "AES_256",
  }),
);

const encryptionKey = await crypto.subtle.importKey(
  "raw",
  generateKeyResponse.Plaintext as Uint8Array<ArrayBuffer>,
  "AES-GCM",
  false,
  ["encrypt"],
);

const iv = crypto.getRandomValues(new Uint8Array(12));

const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, encryptionKey, plaintext);

const header = Buffer.alloc(3);
header.writeUint8(1);
header.writeUint16BE(generateKeyResponse.CiphertextBlob!.byteLength, 1);

console.log({ header });

Check the contents of the header variable:

$ cat pnpm-lock.yaml | node src/encrypt.ts
{ header: <Buffer 01 00 b8> }

The header turned out just as expected: 0x01 followed by the number 184, encoded as two bytes big endian.

Draw the rest of the owl

Now we can print the entire file structure in order. But first, let’s make sure we don’t wind up printing binary data to the terminal. In fact, we can do it right at the top of the file, so we don’t waste precious resources in case the file cannot be written to a terminal:

if (process.stdout.isTTY) {
  console.error("Error: cannot write binary output to a terminal. Pipe the output to a file.");
  process.exit(2);
}

Put the chunks in order and pipe them to standard output:

const chunks = [
  header,
  generateKeyResponse.CiphertextBlob as Uint8Array<ArrayBuffer>,
  iv,
  ciphertext,
];

for (const chunk of chunks) {
  process.stdout.write(new Uint8Array(chunk));
}

Put it all together in src/encrypt.ts:

import { promisify } from "node:util";
import { readFile } from "node:fs";
import { GenerateDataKeyCommand, KMSClient } from "@aws-sdk/client-kms";
import { ENCRYPTION_KEY_ID } from "./config.ts";

if (process.stdout.isTTY) {
  console.error("Error: cannot write binary output to a terminal. Pipe the output to a file.");
  process.exit(2);
}

const readFilePromise = promisify(readFile);
const plaintext = await readFilePromise(process.stdin.fd);

const kmsClient = new KMSClient();

const generateKeyResponse = await kmsClient.send(
  new GenerateDataKeyCommand({
    KeyId: ENCRYPTION_KEY_ID,
    KeySpec: "AES_256",
  }),
);

const encryptionKey = await crypto.subtle.importKey(
  "raw",
  generateKeyResponse.Plaintext as Uint8Array<ArrayBuffer>,
  "AES-GCM",
  false,
  ["encrypt"],
);

const iv = crypto.getRandomValues(new Uint8Array(12));

const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, encryptionKey, plaintext);

const header = Buffer.alloc(3);
header.writeUint8(1);
header.writeUint16BE(generateKeyResponse.CiphertextBlob!.byteLength, 1);

const chunks = [
  header,
  generateKeyResponse.CiphertextBlob as Uint8Array<ArrayBuffer>,
  iv,
  ciphertext,
];

for (const chunk of chunks) {
  process.stdout.write(new Uint8Array(chunk));
}

Let’s test the program. First, let’s try printing output directly to the terminal:

$ cat pnpm-lock.yaml | node src/encrypt.ts
Error: cannot write binary output to a terminal. Pipe the output to a file.

Now, try piping the output to a file:

$ echo -n "All your base are belong to us." | node src/encrypt.ts > ../message.bin
$ echo $?
0

We can inspect the output file using xxd:

$ xxd -p ../message.bin
0100b80102030078a9df1df30bdeeca18f3523f737b83a4ed0537013f820
92df5ade52c0bac3099a013cfb58a658fc58ada994f1e1653bcca2000000
7e307c06092a864886f70d010706a06f306d020100306806092a864886f7
0d010701301e060960864801650304012e3011040cc8becc0e1baa1c9856
16d495020110803b3606db995f8f7470bb3a05ffc2b399f998d82cca8f0c
aaad9351237a45750a084f8c58d4570f951f926466bf76635b5e7cf11e7b
94a22babf07e11872215f22edfc3517bff047ca7719c5b01877aeb4f36e4
fc1de3d430d804ee97c378383e91174730753361b51b6b086b4b60c074b2
bfef4d1b806e

We can see the header: 0x01 0x00 0xB8, followed by an impressive amount of random-looking gibberish. Next, let’s write another program to decode it.

The decryptor program

The decryptor program needs to read the input file and split the binary into chunks, analogously to the encryption process. Let’s start by reading from standard input until we reach EOF. Create src/decrypt.ts:

import { readFile } from "node:fs";
import { promisify } from "node:util";

const readFilePromise = promisify(readFile);
const input = await readFilePromise(process.stdin.fd);

Verify that the returned buffer is not empty.

if (input.length === 0) {
  console.error("Failed to read from standard input.");
  process.exit(1);
}

Next, validate the version marker, stored in the first byte of the file. It must equal 0x01, otherwise abort the decryption:

const versionMarker = input.readUint8();
if (versionMarker !== 0x01) {
  const versionMarkerHex = versionMarker.toString(16).padStart(2, "0");
  console.error(`Unsupported version 0x${versionMarkerHex}.`);
  process.exit(1);
}

Read the length of the wrapped Data Encryption Key from bytes 1–2 of the input (16-bit unsigned integer, big endian):

const keyLength = input.readUint16BE(1);

Next, read keyLength bytes from the buffer, starting from index 3 (the fourth byte of the input buffer).

const wrappedKey = input.subarray(3, 3 + keyLength);

If the resulting buffer is shorter than expected, it means that the input file is too short and we should abort the operation:

if (wrappedKey.byteLength !== keyLength) {
  console.error(
    `Failed to read wrapped key from input: Want ${keyLength} bytes, got ${wrappedKey.byteLength}.`,
  );
  process.exit(1);
}

Next, read the IV, which is the following 12 bytes, starting right after byte 3 + keyLength. If the resulting binary is shorter than 12 bytes, abort the operation:

const iv = input.subarray(3 + keyLength, 3 + keyLength + 12);
if (iv.byteLength !== 12) {
  console.error("Input too short.");
  process.exit(1);
}

The remaining part of the file is the ciphertext and authentication tag:

const ciphertext = input.subarray(3 + keyLength + 12);

Next, call the AWS KMS API to unwrap the Data Encryption Key:

import { DecryptCommand, KMSClient } from "@aws-sdk/client-kms";
import { ENCRYPTION_KEY_ID } from "./config.ts";

const kmsClient = new KMSClient();
const unwrappedKey = await kmsClient.send(
  new DecryptCommand({
    CiphertextBlob: wrappedKey,
    KeyId: ENCRYPTION_KEY_ID,
  }),
);

Convert the unwrapped key to a CryptoKey:

const decryptionKey = await crypto.subtle.importKey(
  "raw",
  unwrappedKey.Plaintext as Uint8Array<ArrayBuffer>,
  "AES-GCM",
  false,
  ["decrypt"],
);

With the last part in place, we can decrypt the ciphertext and print it out to standard output:

const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, decryptionKey, ciphertext);
process.stdout.write(new Uint8Array(plaintext));

Let’s put it all together in src/decrypt.ts

import { DecryptCommand, KMSClient } from "@aws-sdk/client-kms";
import { readFile } from "node:fs";
import { promisify } from "node:util";
import { ENCRYPTION_KEY_ID } from "./config.ts";

const readFilePromise = promisify(readFile);
const input = await readFilePromise(process.stdin.fd);

if (input.length === 0) {
  console.error("Failed to read from standard input.");
  process.exit(1);
}

const versionMarker = input.readUint8();
if (versionMarker !== 0x01) {
  const versionMarkerHex = versionMarker.toString(16).padStart(2, "0");
  console.error(`Unsupported version 0x${versionMarkerHex}.`);
  process.exit(1);
}

const keyLength = input.readUint16BE(1);
const wrappedKey = input.subarray(3, 3 + keyLength);
if (wrappedKey.byteLength !== keyLength) {
  console.error(
    `Failed to read wrapped key from input: Want ${keyLength} bytes, got ${wrappedKey.byteLength}.`,
  );
  process.exit(1);
}

const iv = input.subarray(3 + keyLength, 3 + keyLength + 12);
if (iv.byteLength !== 12) {
  console.error("Input too short.");
  process.exit(1);
}

const ciphertext = input.subarray(3 + keyLength + 12);

const kmsClient = new KMSClient();
const unwrappedKey = await kmsClient.send(
  new DecryptCommand({
    CiphertextBlob: wrappedKey,
    KeyId: ENCRYPTION_KEY_ID,
  }),
);

const decryptionKey = await crypto.subtle.importKey(
  "raw",
  unwrappedKey.Plaintext as Uint8Array<ArrayBuffer>,
  "AES-GCM",
  false,
  ["decrypt"],
);

const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, decryptionKey, ciphertext);

process.stdout.write(new Uint8Array(plaintext));

If we call the program with the ciphertext generated by src/encrypt.ts, we should obtain the original message:

$ cat ../message.bin | node src/decrypt.ts
All your base are belong to us.

Learnings

  1. Use fs.readFile to read from the standard input. Use promisify to convert the callback-based implementation to one returning a Promise.

  2. You can access the standard output directly as process.stdout. Since it is a writable stream, you can write binary data to it using the write method.

  3. Use TypedArray.prototype.subarray to create a slice of an existing Buffer or TypedArray.