EarnMorev1.0.0
pan.tstypescript
/**
 * earnmore — 16-digit PAN generation (PCI-DSS v4.0 compliant)
 *
 * Design:
 *  1. Compute SHA-256 over (customerFingerprint + randomSalt) → combinedHash
 *  2. Feed combinedHash into HmacDrbg with domain context "earnmore:pan"
 *  3. Select BIN prefix using unbiased rejection-sampled draw
 *  4. Fill digits 5-15 using rejection-sampled draws in [0, 10)
 *  5. Compute Luhn check digit for position 16
 *
 * Non-regeneratable guarantee:
 *  The randomSalt (crypto.randomBytes(16)) is mixed in before hashing, so
 *  repeated calls for the same customer always produce a distinct PAN.
 *
 * CSPRNG compliance (PCI-DSS Req 6.2.4 / A3.3):
 *  All digit selection uses HmacDrbg with rejection sampling — no modular bias.
 */

import { createHash, randomBytes } from "crypto";
import { ALLOWED_PREFIXES, AllowedPrefix } from "./types";
import { HmacDrbg } from "./hash";

/**
 * Computes the Luhn check digit for a partial PAN (digits 1-15).
 * Returns a single digit (0-9) to append as digit 16.
 */
export function luhnCheckDigit(partial: string): number {
  const digits = (partial + "0").split("").map(Number);
  let sum = 0;

  for (let i = digits.length - 1; i >= 0; i--) {
    // eslint-disable-next-line security/detect-object-injection
    let d = digits[i];
    if ((digits.length - 1 - i) % 2 === 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }

  return (10 - (sum % 10)) % 10;
}

/**
 * Validates that a 16-digit PAN passes the Luhn algorithm.
 */
export function luhnValid(pan: string): boolean {
  if (pan.length !== 16 || !/^\d{16}$/.test(pan)) return false;

  const digits = pan.split("").map(Number);
  let sum = 0;

  for (let i = digits.length - 1; i >= 0; i--) {
    // eslint-disable-next-line security/detect-object-injection
    let d = digits[i];

    if ((digits.length - 1 - i) % 2 === 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }

  return sum % 10 === 0;
}

export interface PanResult {
  /** 16-digit PAN string */
  pan: string;

  /** The selected BIN prefix */
  prefix: AllowedPrefix;

  /**
   * The combinedHash buffer — used downstream to seed the expiry HMAC-DRBG.
   * Both PAN and expiry streams are domain-separated from this shared seed.
   */
  seedBuffer: Buffer;
}

/**
 * Generates a unique, non-regeneratable 16-digit PAN.
 *
 * @param fingerprint  SHA-256 hex fingerprint of the customer's identity
 */
export function generatePAN(fingerprint: string): PanResult {
  const salt = randomBytes(16);
  const combined = createHash("sha256").update(fingerprint, "hex").update(salt).digest();
  const drbg = new HmacDrbg(combined, "earnmore:pan");
  const prefixIndex = drbg.nextInt(ALLOWED_PREFIXES.length);

  // eslint-disable-next-line security/detect-object-injection
  const prefix = ALLOWED_PREFIXES[prefixIndex];
  let middle = "";

  for (let i = 0; i < 11; i++) {
    middle += String(drbg.nextInt(10));
  }

  const partial = prefix + middle;
  const check = luhnCheckDigit(partial);
  const pan = partial + String(check);

  return {
    pan,
    prefix,
    seedBuffer: combined,
  };
}