EarnMorev1.0.0
hash.tstypescript
/**
 * earnmore — Hashing & CSPRNG utilities
 *
 * PCI-DSS Req 6.2.4 / A3.3: All random number generation used in card data
 * production must use a cryptographically secure source.
 *
 * NIST SP 800-218 (SSDF): Input validation is enforced on all customer fields
 * to prevent denial-of-service via excessively large payloads.
 */

import { createHash, createHmac } from "crypto";
import { CustomerInfo } from "./types";

/** Maximum characters allowed per customer identity field */
const MAX_FIELD_LENGTH = 1024;

/** Maximum total character count across all customer fields combined */
const MAX_TOTAL_PAYLOAD = 5 * 1024; // 5 KB

/**
 * Validates that birthPlace contains only letters and spaces.
 * Throws a descriptive error if it doesn't.
 */
export function validateBirthPlace(birthPlace: string): void {
  if (!/^[A-Za-z\s]+$/.test(birthPlace)) {
    throw new Error(`Invalid birthPlace: must contain letters and spaces only.`);
  }
}

/**
 * Validates customer identity field lengths per NIST SP 800-218 / ISO 27002:2022 A.8.25.
 * Enforces per-field and total-payload limits to prevent DoS via large inputs.
 *
 * @throws {Error} If any individual field exceeds MAX_FIELD_LENGTH, or the
 *                 combined total exceeds MAX_TOTAL_PAYLOAD.
 */
function validateFieldLengths(info: CustomerInfo): void {
  const fields: Array<[string, string]> = [
    ["firstName", info.firstName],
    ["middleName", info.middleName],
    ["lastName", info.lastName],
    ["address.line1", info.address.line1],
    ["address.line2", info.address.line2 ?? ""],
    ["address.city", info.address.city],
    ["address.country", info.address.country],
    ["address.zipCode", info.address.zipCode],
    ["birthPlace", info.birthPlace],
    ["idDocument.number", info.idDocument.number],
  ];

  for (const [name, value] of fields) {
    if (value.length > MAX_FIELD_LENGTH) {
      throw new Error(
        `Field "${name}" exceeds the maximum allowed length of ${MAX_FIELD_LENGTH} characters ` +
          `(received ${value.length}). Per NIST SP 800-218, input length limits prevent DoS attacks.`,
      );
    }
  }

  const total = fields.reduce((sum, [, v]) => sum + v.length, 0);
  if (total > MAX_TOTAL_PAYLOAD) {
    throw new Error(
      `Total customer data payload exceeds ${MAX_TOTAL_PAYLOAD} bytes (received ${total}). ` +
        `Per NIST SP 800-218, total input size is bounded to prevent DoS.`,
    );
  }
}

/**
 * Scrubs a CustomerInfo object by overwriting its string fields with zeroes.
 *
 * In JavaScript, memory cannot be manually freed, but mutating the string
 * properties in-place ensures the original PII is removed from the active
 * object immediately before the garbage collector reclaims it.
 * This aids in complying with data minimization and Right to be Forgotten
 * principles in environments where memory dumps could expose PII.
 *
 * Note: Strings in JS are immutable. This sets the property to a NEW string
 * of zeroes. The original string becomes eligible for GC immediately.
 *
 * @param info The CustomerInfo object to scrub
 */
export function scrubCustomerData(info: CustomerInfo): void {
  const scrubString = (s: string) => "0".repeat(s.length);

  if (info.firstName) info.firstName = scrubString(info.firstName);
  if (info.middleName) info.middleName = scrubString(info.middleName);
  if (info.lastName) info.lastName = scrubString(info.lastName);
  if (info.birthPlace) info.birthPlace = scrubString(info.birthPlace);

  if (info.address) {
    if (info.address.line1) info.address.line1 = scrubString(info.address.line1);
    if (info.address.line2) info.address.line2 = scrubString(info.address.line2);
    if (info.address.city) info.address.city = scrubString(info.address.city);
    if (info.address.country) info.address.country = scrubString(info.address.country);
    if (info.address.zipCode) info.address.zipCode = scrubString(info.address.zipCode);
  }

  if (info.idDocument) {
    if (info.idDocument.number) info.idDocument.number = scrubString(info.idDocument.number);
  }
}

/**
 * Produces a deterministic SHA-256 hex fingerprint from all customer fields.
 * Fields are normalized (trimmed, lowercased, date as ISO YYYY-MM-DD) so that
 * minor formatting differences do not produce different fingerprints.
 *
 * This fingerprint is used as an entropy *input* mixed with a random salt —
 * it is never the sole source of PAN uniqueness.
 *
 * Input validation (NIST SP 800-218):
 *  - birthPlace: letters and spaces only
 *  - Individual fields: max 512 characters each
 *  - Total payload: max 5 KB
 */
export function customerFingerprint(info: CustomerInfo): string {
  validateBirthPlace(info.birthPlace);
  validateFieldLengths(info);

  const parts: string[] = [
    info.firstName.trim().toLowerCase(),
    info.middleName.trim().toLowerCase(),
    info.lastName.trim().toLowerCase(),
    info.address.line1.trim().toLowerCase(),
    (info.address.line2 ?? "").trim().toLowerCase(),
    info.address.city.trim().toLowerCase(),
    info.address.country.trim().toLowerCase(),
    info.address.zipCode.trim().toLowerCase(),
    new Date(info.birthday).toISOString().split("T")[0],
    info.birthPlace.trim().toLowerCase(),
    info.idDocument.type,
    info.idDocument.number.trim().toLowerCase(),
  ];

  const raw = parts.join("|");
  return createHash("sha256").update(raw, "utf8").digest("hex");
}

/**
 * HMAC-SHA256 Deterministic Random Bit Generator.
 * Implements the formal NIST SP 800-90A Rev.1 HMAC_DRBG specification.
 *
 * Properties:
 *  - Cryptographically secure (HMAC-SHA256 as PRF)
 *  - Domain-separated via the context string (prevents cross-use of the same
 *    seed for different purposes, e.g. "earnmore:pan" vs "earnmore:expiry")
 *  - Rejection-sampled outputs eliminate modular bias (FIPS 186-5 §B.4.2)
 *
 * @see NIST SP 800-90A Rev.1 — HMAC_DRBG (formal standard)
 * @see FIPS 186-5 §B.4.2 — Rejection sampling for unbiased output
 */
export class HmacDrbg {
  private V: Buffer;
  private Key: Buffer;
  private reseedCounter: number;
  private block: Buffer = Buffer.alloc(0);
  private blockOffset: number = 0;

  /**
   * Instantiates the HMAC_DRBG state machine (SP 800-90A §10.1.2.3).
   *
   * @param seed     32-byte Buffer from which all randomness is derived.
   *                 Must originate from a CSPRNG (e.g. crypto.randomBytes).
   * @param context  Domain-separation string. Used as `personalization_string`.
   */
  constructor(seed: Buffer, context: string = "") {
    this.Key = Buffer.alloc(32, 0x00);
    this.V = Buffer.alloc(32, 0x01);

    const seedMaterial = Buffer.concat([seed, Buffer.from(context, "utf8")]);
    this.update(seedMaterial);
    this.reseedCounter = 1;
  }

  /**
   * HMAC_DRBG Update Function (SP 800-90A §10.1.2.2).
   */
  private update(providedData?: Buffer): void {
    let mac = createHmac("sha256", this.Key);
    mac.update(this.V);
    mac.update(Buffer.from([0x00]));

    if (providedData) mac.update(providedData);

    this.Key = mac.digest();
    this.V = createHmac("sha256", this.Key).update(this.V).digest();

    if (!providedData) return;

    mac = createHmac("sha256", this.Key);
    mac.update(this.V);
    mac.update(Buffer.from([0x01]));
    mac.update(providedData);

    this.Key = mac.digest();
    this.V = createHmac("sha256", this.Key).update(this.V).digest();
  }

  /**
   * Executes a Generate request for 32 bytes (SP 800-90A §10.1.2.5).
   */
  private generateBlock(): void {
    if (this.reseedCounter > 10000) {
      throw new Error("HMAC_DRBG reseed interval exceeded.");
    }

    this.V = createHmac("sha256", this.Key).update(this.V).digest();
    this.block = Buffer.from(this.V);
    this.blockOffset = 0;

    this.update();
    this.reseedCounter++;
  }

  /** Returns the next byte from the DRBG output stream */
  nextByte(): number {
    if (this.blockOffset >= this.block.length) {
      this.generateBlock();
    }
    return this.block[this.blockOffset++];
  }

  /**
   * Returns an unbiased random integer in [0, max) using rejection sampling.
   * Rejects bytes that would introduce modular bias (FIPS 186-5 §B.4.2).
   *
   * @param max  Upper bound (exclusive). Must be in [2, 256].
   */
  nextInt(max: number): number {
    if (max < 2 || max > 256) {
      throw new RangeError(`nextInt max must be in [2, 256], got ${max}`);
    }

    const threshold = 256 - (256 % max);
    for (;;) {
      const b = this.nextByte();
      if (b < threshold) return b % max;
    }
  }
}