EarnMorev1.0.0
storage.tstypescript
/**
 * earnmore — PAN encryption for secure at-rest storage (PCI-DSS Req 3.4.1)
 *
 * PCI-DSS Req 3.4.1 requires that PANs stored anywhere (database, log, backup)
 * be rendered unreadable using strong cryptography.
 *
 * This module provides AES-256-GCM encryption for PAN storage:
 *  - AES-256-GCM: authenticated encryption (confidentiality + integrity)
 *  - 12-byte random IV per operation (NIST SP 800-38D recommendation)
 *  - 128-bit GCM auth tag validates integrity on decryption
 *
 * Key management (PCI-DSS Req 3.7):
 *  - The encryption key (32 bytes / 256 bits) is the caller's responsibility.
 *  - It MUST be different from the CVV HMAC passkey (Req 3.7.5 key separation).
 *  - Rotate and re-encrypt stored PANs when the key is rotated.
 *  - Store the key separately from the encrypted PAN data.
 */

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
import { EncryptedPAN } from "./types";

const ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_BYTES = 32;

/**
 * Encrypts a 16-digit PAN using AES-256-GCM.
 *
 * @param pan            16-digit PAN string
 * @param encryptionKey  32-byte (256-bit) encryption key Buffer.
 *                       Must be distinct from the CVV HMAC passkey.
 * @returns              EncryptedPAN with iv, ciphertext, and authTag (all hex)
 *
 * @throws {Error}  If PAN is not 16 digits or key is not 32 bytes.
 */
export function encryptPAN(pan: string, encryptionKey: Buffer): EncryptedPAN {
  if (!/^\d{16}$/.test(pan)) {
    throw new Error(`Invalid PAN: must be exactly 16 digits.`);
  }

  if (!Buffer.isBuffer(encryptionKey) || encryptionKey.length !== KEY_BYTES) {
    throw new Error(
      `encryptionKey must be a ${KEY_BYTES}-byte Buffer (256-bit). ` +
        `Received ${encryptionKey?.length ?? "unknown"} bytes.`,
    );
  }

  const iv = randomBytes(IV_BYTES);
  const cipher = createCipheriv(ALGORITHM, encryptionKey, iv, {
    authTagLength: TAG_BYTES,
  });

  const ciphertext = Buffer.concat([cipher.update(pan, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();

  return {
    iv: iv.toString("hex"),
    ciphertext: ciphertext.toString("hex"),
    authTag: authTag.toString("hex"),
  };
}

/**
 * Decrypts an EncryptedPAN using AES-256-GCM.
 * GCM authentication tag is verified before any plaintext is returned —
 * if the tag fails, an error is thrown and no plaintext is exposed.
 *
 * @param encrypted      EncryptedPAN object from encryptPAN()
 * @param encryptionKey  The same 32-byte key used during encryption
 * @returns              The original 16-digit PAN string
 *
 * @throws {Error}  If key is wrong, data is tampered, or input is malformed.
 */
export function decryptPAN(encrypted: EncryptedPAN, encryptionKey: Buffer): string {
  if (!Buffer.isBuffer(encryptionKey) || encryptionKey.length !== KEY_BYTES) {
    throw new Error(`encryptionKey must be a ${KEY_BYTES}-byte Buffer (256-bit).`);
  }

  const iv = Buffer.from(encrypted.iv, "hex");
  const ciphertext = Buffer.from(encrypted.ciphertext, "hex");
  const authTag = Buffer.from(encrypted.authTag, "hex");

  if (iv.length !== IV_BYTES) {
    throw new Error(`Invalid IV length: expected ${IV_BYTES} bytes.`);
  }
  if (authTag.length !== TAG_BYTES) {
    throw new Error(`Invalid auth tag length: expected ${TAG_BYTES} bytes.`);
  }

  const decipher = createDecipheriv(ALGORITHM, encryptionKey, iv, {
    authTagLength: TAG_BYTES,
  });
  decipher.setAuthTag(authTag);

  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return plaintext.toString("utf8");
}

/**
 * Generates a random 32-byte AES-256 encryption key.
 * Use this to create a key for encryptPAN() / decryptPAN().
 *
 * ⚠️ Store the returned key securely (HSM, secret manager, KMS).
 *    This key must be kept separate from the CVV passkey.
 */
export function generateEncryptionKey(): Buffer {
  return randomBytes(KEY_BYTES);
}