In the previous post I described an ECS Fargate container that downloads encrypted files from a pre-signed URL, decrypts them, and uploads the plaintext to S3. I glossed over the encryption scheme itself. This post digs into it.

The pattern is called envelope encryption. It is the same scheme used under the hood by AWS KMS, PGP, TLS, and nearly every other system that needs to transfer encrypted data between parties. Understanding it is useful because it solves a real problem in a general way –and because the Node.js crypto module exposes the underlying primitives directly, making it straightforward to implement without a third-party library.

The Problem With Encrypting Directly With RSA

RSA can encrypt data directly, but it has a hard size limit: you can only encrypt a payload up to roughly key_size_bytes - padding_overhead bytes. For a 2048-bit key with PKCS#1 v1.5 padding, that is 245 bytes. For a 4096-bit key, 501 bytes. A CSV file with a day’s worth of records will not fit.

The other option –using only a symmetric cipher like AES –removes the size limit but introduces a key distribution problem. How does the data provider encrypt the file in a way that only the intended consumer can decrypt? If both parties share the same AES key, you need a secure channel to exchange it. If you use a different AES key per file, how do you share each one?

Envelope encryption solves both problems.

How Envelope Encryption Works

Data Provider (sender)                    Consumer (receiver)

1. Generate random AES key + IV
2. Encrypt file content with AES key
3. Encrypt AES key with consumer's RSA public key
4. Deliver:
     - Encrypted file (via pre-signed S3 URL)
     - Encrypted AES key (base64)
     - IV (base64)
                           ───────────────►
                                              5. Retrieve RSA private key
                                              6. Decrypt AES key with private key
                                              7. Decrypt file content with AES key

The AES key is the “envelope.” It is generated fresh for every file –so even if one key is compromised, no other file is affected. The RSA key pair handles the key distribution problem: the consumer’s public key is known to the provider, the private key never leaves the consumer’s control.

The Provider Side: Encrypting

Here is the relevant section of the mock data provider, simplified and stripped of domain specifics:

import * as crypto from 'crypto';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function encryptAndUpload(
  bucket: string,
  consumerPublicKeyPem: string,
  plaintext: string
): Promise<{ presignedUrl: string; encryptedKey: string; encodedIV: string }> {

  // 1. Generate a fresh AES-128 key and IV for this file only
  const aesKey = crypto.randomBytes(16); // 128-bit key
  const iv     = crypto.randomBytes(16); // 128-bit IV (AES block size)

  // 2. Encrypt the plaintext with AES-128-CBC
  const cipher    = crypto.createCipheriv('aes-128-cbc', aesKey, iv);
  let   encrypted = cipher.update(plaintext, 'utf-8', 'base64');
  encrypted      += cipher.final('base64');

  // 3. Upload ciphertext to S3
  const s3Key = `data/${crypto.randomBytes(12).toString('base64url')}`;
  await s3.send(new PutObjectCommand({
    Bucket: bucket,
    Key:    s3Key,
    Body:   Buffer.from(encrypted, 'base64')
  }));

  // 4. Generate a pre-signed URL (valid for 30 minutes)
  const presignedUrl = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
    { expiresIn: 1800 }
  );

  // 5. Encrypt the AES key with the consumer's RSA public key
  //    Only the holder of the matching private key can unwrap this
  const encryptedKey = crypto.publicEncrypt(consumerPublicKeyPem, aesKey);

  return {
    presignedUrl,
    encryptedKey: encryptedKey.toString('base64'),
    encodedIV:    iv.toString('base64')
  };
}

Three things to note:

  • crypto.randomBytes(16) for both key and IV. Never reuse an IV with the same key. Generating a fresh IV per file (and transmitting it alongside the ciphertext) is the correct approach.
  • The AES key is only 128 bits. AES-128 is secure, but AES-256 is preferred for new systems. This is a known limitation in the codebase (there is a TODO comment to upgrade it). The fix is changing randomBytes(16) to randomBytes(32) and swapping aes-128-cbc for aes-256-cbc on both sides.
  • publicEncrypt uses PKCS#1 v1.5 padding by default. The padding: 1 constant passed in the consumer code is crypto.constants.RSA_PKCS1_PADDING. This is legacy –OAEP (RSA_PKCS1_OAEP_PADDING) is the modern standard and should be preferred. More on this below.

The Consumer Side: Decrypting

The consumer receives three things: a pre-signed URL, a base64-encoded encrypted AES key, and a base64-encoded IV. It has the RSA private key available as a PEM string (injected by ECS from Secrets Manager at task start).

import { createDecipheriv, privateDecrypt } from 'crypto';

async function processFile(record: JobRecord, privateKeyPem: string): Promise<void> {
  const { PresignedURL, EncryptedAESKey, EncodedAESIV } = record;

  // Step 1: Download the encrypted file
  const response = await fetch(PresignedURL);
  if (!response.ok) throw new Error(`Download failed: ${response.status}`);
  const encryptedData = Buffer.from(await response.arrayBuffer());

  // Step 2: Unwrap the AES key using the RSA private key
  const encryptedKeyBuffer = Buffer.from(EncryptedAESKey, 'base64');
  const aesKey = privateDecrypt(
    { key: privateKeyPem, padding: 1 }, // 1 = RSA_PKCS1_PADDING
    encryptedKeyBuffer
  );

  // Step 3: Decode the IV
  const aesIV = Buffer.from(EncodedAESIV, 'base64');

  // Step 4: Decrypt the file content
  const decipher     = createDecipheriv('aes-128-cbc', aesKey, aesIV);
  const decryptedData = Buffer.concat([
    decipher.update(encryptedData),
    decipher.final()
  ]);

  // Step 5: Upload plaintext to S3
  await s3.send(new PutObjectCommand({
    Bucket:               OUTPUT_BUCKET,
    Key:                  `${record.FileID}.csv`,
    Body:                 decryptedData,
    ContentType:          'text/csv',
    ServerSideEncryption: 'AES256'
  }));
}

The sequence exactly mirrors the provider side in reverse:

  1. Buffer.from(EncryptedAESKey, 'base64') –decode the base64 back to raw bytes
  2. privateDecrypt(...) –RSA-unwrap the AES key
  3. Buffer.from(EncodedAESIV, 'base64') –decode the IV
  4. createDecipheriv('aes-128-cbc', aesKey, aesIV) –initialise the AES cipher in decrypt mode
  5. decipher.update(data) + decipher.final() –produce the plaintext

decipher.final() strips CBC padding (PKCS#7) from the last block. If the padding bytes are invalid –which happens when you decrypt with a wrong key or corrupted ciphertext –final() throws. This is intentional: a thrown error means the file is poisoned and the status is updated to failed in DynamoDB.

Key Management: Where the Private Key Lives

The private key never touches the application code at deploy time. It is stored in AWS Secrets Manager and injected into the container at task start via the ECS task definition secrets block:

secrets = [
  {
    name      = "PRIVATE_KEY_SECRET_ARN"  # env var name in the container
    valueFrom = aws_secretsmanager_secret.private_key.arn
  }
]

ECS retrieves the secret value and sets PRIVATE_KEY_SECRET_ARN as a plain string environment variable before the process starts. The container reads it with process.env.PRIVATE_KEY_SECRET_ARN. The secret never appears in the task definition itself, in CloudWatch logs (unless you accidentally log it), or in the container image.

The public key is registered with the data provider out-of-band –typically during onboarding. In the mock, it is stored in Secrets Manager under the consumer’s agent ID and fetched when generating the encrypted file:

// Provider: fetch consumer's registered public key by agent ID
const secretsResponse = await secretsManager.send(
  new GetSecretValueCommand({ SecretId: agentId })
);
const consumerPublicKeyPem = secretsResponse.SecretString!;

This registration step means the provider never needs to distribute or manage encryption keys directly. Adding a new consumer is a matter of registering their public key. Rotating the consumer’s key pair means updating the secret and re-registering the public key –without touching the provider’s infrastructure.

A Minimal Working Example

To verify the scheme in isolation, outside of any AWS infrastructure:

import * as crypto from 'crypto';
import { createDecipheriv, createCipheriv, privateDecrypt, publicEncrypt } from 'crypto';

// Generate a test RSA key pair (use 2048+ in production; 4096 for long-term keys)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048
});

// === PROVIDER SIDE ===
const plaintext = 'entity,date,amount\nACCOUNT,2026-02-28,1234.56\n';

const aesKey = crypto.randomBytes(16);
const iv     = crypto.randomBytes(16);

const cipher    = createCipheriv('aes-128-cbc', aesKey, iv);
let   encrypted = cipher.update(plaintext, 'utf-8');
const finalBlock = cipher.final();
const encryptedData = Buffer.concat([encrypted, finalBlock]);

const encryptedAESKey = publicEncrypt(publicKey, aesKey);

// These three values are what the provider delivers to the consumer:
const payload = {
  encryptedData:   encryptedData.toString('base64'),
  encryptedAESKey: encryptedAESKey.toString('base64'),
  iv:              iv.toString('base64')
};

console.log('Payload size (bytes):');
console.log('  Encrypted data:', encryptedData.length);
console.log('  Encrypted AES key:', encryptedAESKey.length); // 256 bytes for RSA-2048

// === CONSUMER SIDE ===
const receivedData       = Buffer.from(payload.encryptedData,   'base64');
const receivedEncryptedKey = Buffer.from(payload.encryptedAESKey, 'base64');
const receivedIV         = Buffer.from(payload.iv,               'base64');

const recoveredAESKey = privateDecrypt(
  { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
  receivedEncryptedKey
);

const decipher      = createDecipheriv('aes-128-cbc', recoveredAESKey, receivedIV);
const decryptedData = Buffer.concat([decipher.update(receivedData), decipher.final()]);

console.log('\nDecrypted output:');
console.log(decryptedData.toString('utf-8'));
// → entity,date,amount
//   ACCOUNT,2026-02-28,1234.56

Running this confirms the round-trip. The encrypted AES key is always 256 bytes (for RSA-2048) regardless of how large the plaintext is –the RSA operation only ever touches the 16-byte AES key.

PKCS#1 v1.5 vs OAEP

The implementation uses padding: 1 (RSA_PKCS1_PADDING), which is PKCS#1 v1.5. This is the legacy padding scheme and has known vulnerabilities, most notably the Bleichenbacher attack –a padding oracle that allows an adversary to decrypt RSA ciphertexts if they can query a decryption oracle repeatedly.

In this architecture, the private key decryption happens inside a Fargate container with no externally accessible API. There is no decryption oracle to query. But PKCS#1 v1.5 should still be considered deprecated for new code.

The fix is one constant change on both sides:

// Provider (encryption)
const encryptedAESKey = publicEncrypt(
  { key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING },
  aesKey
);

// Consumer (decryption)
const recoveredAESKey = privateDecrypt(
  { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING },
  receivedEncryptedKey
);

OAEP adds randomness to the padding, making it semantically secure (the same plaintext encrypted twice produces different ciphertexts) and resistant to padding oracle attacks. The default hash for OAEP in Node.js is SHA-1, which is weak –specify SHA-256:

crypto.publicEncrypt(
  {
    key:            publicKey,
    padding:        crypto.constants.RSA_PKCS1_OAEP_PADDING,
    oaepHash:       'sha256'
  },
  aesKey
);

Both sides must agree on the same oaepHash value, otherwise decryption fails with a padding error.

AES-128-CBC vs AES-256-CBC vs AES-GCM

AES-128 vs AES-256: The security difference is academic in practice –no known attack against AES-128 is computationally feasible. AES-256 is preferred for regulatory and compliance reasons (e.g. NIST SP 800-57, FIPS 140-2/3). If your environment has compliance requirements, use AES-256. The code change is randomBytes(16)randomBytes(32) and aes-128-cbcaes-256-cbc.

CBC vs GCM: AES-CBC provides confidentiality only –it does not detect tampering. An attacker with access to the S3 pre-signed URL could potentially modify the ciphertext, and the consumer would decrypt garbage without knowing it was tampered with. AES-GCM (Galois/Counter Mode) provides both confidentiality and integrity via an authentication tag. If the ciphertext is modified, decryption fails with an authentication error before any plaintext is produced.

AES-256-GCM with a 12-byte random nonce is the modern recommendation:

// Provider
const key   = crypto.randomBytes(32); // AES-256
const nonce = crypto.randomBytes(12); // GCM standard nonce size

const cipher     = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const authTag    = cipher.getAuthTag(); // 16-byte integrity tag

// Deliver: ciphertext, nonce (base64), authTag (base64), encryptedKey (RSA-wrapped)

// Consumer
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(authTag);
// Throws if ciphertext was tampered with --plaintext is never returned
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

The tradeoff is an additional 12 bytes (nonce) + 16 bytes (auth tag) in the payload, which is negligible. The implementation change is small. The security gain is significant.

The Security Properties of the Full Scheme

Putting it together, the current scheme (RSA-2048/PKCS1v1.5 + AES-128-CBC) provides:

Property Status Notes
Confidentiality (data in transit) AES-128-CBC encryption
Confidentiality (key in transit) RSA-2048 wrapping
Integrity CBC provides no authentication
Forward secrecy ✓ (partial) Per-file AES keys; compromising one key doesn’t affect others
Key isolation Private key stays in Secrets Manager, never in code or image
Replay protection Pre-signed URLs expire after 30 minutes

Upgrading to RSA/OAEP + AES-256-GCM adds integrity and stronger padding, making the scheme production-grade for regulated environments.

Summary

Envelope encryption –generate a random symmetric key, encrypt the data with it, encrypt the key with the recipient’s public key –is the right pattern whenever you need to transmit encrypted data to a specific party at scale. The symmetric cipher handles the bulk data efficiently; the asymmetric operation handles only the 16–32 byte key, well within RSA’s size limits.

The Node.js crypto module provides all the primitives (randomBytes, publicEncrypt, privateDecrypt, createCipheriv, createDecipheriv) without any external dependencies. The pitfalls are mostly in the details: PKCS#1 v1.5 vs OAEP, CBC vs GCM, key length, IV reuse. Getting those right is the difference between a scheme that is correct in principle and one that is robust in practice.