Source

shared/crypto.ts

import * as x509 from "@peculiar/x509";
import { Crypto, CryptoKey } from "@peculiar/webcrypto";
import { stringFormat } from "@decaf-ts/decorator-validation";
import { isBrowser, MiniLogger } from "@decaf-ts/logging";

const crypto = new Crypto();
x509.cryptoProvider.set(crypto);

export enum BASE_ALPHABET {
  BASE2 = "01",
  BASE8 = "01234567",
  BASE11 = "0123456789a",
  BASE16 = "0123456789abcdef",
  BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
  BASE32_Z = "ybndrfg8ejkmcpqxot1uwisza345h769",
  BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz",
  BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
  BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
  BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
  BASE67 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~",
}

export type keyObject = {
  iv: ArrayBuffer;
  key: CryptoKey;
};

export enum CRYPTO {
  HASH = "SHA-256",
  ITERATIONS = 1000,
  KEYLENGTH = 48,
  DERIVED_IV_LENGTH = 16,
  DERIVED_KEY_LENGTH = 32, // Because SHA-256 used has a native size of 32 bytes
  ALGORYTHM = "AES-GCM",
  KEY_ALGORYTHM = "PBKDF2",
}

export class BaseEncoder {
  private readonly baseMap: Uint8Array = new Uint8Array(256);
  private readonly base: number;
  private readonly leader: string;
  private readonly factor: number;
  private readonly iFactor: number;

  constructor(private alphabet: BASE_ALPHABET) {
    if (this.alphabet.length >= 255) throw new Error("Alphabet too long");

    for (let j = 0; j < this.baseMap.length; j++) this.baseMap[j] = 255;

    for (let i = 0; i < alphabet.length; i++) {
      const x = alphabet.charAt(i);
      const xc = x.charCodeAt(0);
      if (this.baseMap[xc] !== 255) throw new Error(x + " is ambiguous");

      this.baseMap[xc] = i;
    }

    this.base = this.alphabet.length;
    this.leader = this.alphabet.charAt(0);
    this.factor = Math.log(this.base) / Math.log(256); // log(BASE) / log(256), rounded up
    this.iFactor = Math.log(256) / Math.log(this.base); // log(256) / log(BASE), rounded up
  }

  encode(source: Uint8Array | DataView | any[] | string) {
    if (typeof source === "string") {
      source = Buffer.from(source);
    } else if (ArrayBuffer.isView(source)) {
      source = new Uint8Array(
        source.buffer,
        source.byteOffset,
        source.byteLength
      );
    } else if (Array.isArray(source)) {
      source = Uint8Array.from(source);
    }

    if (source.length === 0) return "";

    // Skip & count leading zeroes.
    let zeroes = 0;
    let length = 0;
    let pbegin = 0;
    const pend = source.length;
    while (pbegin !== pend && source[pbegin] === 0) {
      pbegin++;
      zeroes++;
    }
    // Allocate enough space in big-endian base58 representation.
    const size = ((pend - pbegin) * this.iFactor + 1) >>> 0;
    const b58 = new Uint8Array(size);
    // Process the bytes.
    while (pbegin !== pend) {
      let carry = source[pbegin];
      // Apply "b58 = b58 * 256 + ch".
      let i = 0;
      for (
        let it1 = size - 1;
        (carry !== 0 || i < length) && it1 !== -1;
        it1--, i++
      ) {
        carry += (256 * b58[it1]) >>> 0;
        b58[it1] = carry % this.base >>> 0;
        carry = (carry / this.base) >>> 0;
      }
      if (carry !== 0) throw new Error("Non-zero carry");

      length = i;
      pbegin++;
    }
    // Skip leading zeroes in base58 result.
    let it2 = size - length;
    while (it2 !== size && b58[it2] === 0) it2++;

    // Translate the result into a string.
    let str = this.leader.repeat(zeroes);
    for (; it2 < size; ++it2) {
      str += this.alphabet.charAt(b58[it2]);
    }
    return str;
  }

  private decodeUnsafe(source: string): Uint8Array | undefined {
    if (source.length === 0) return new Uint8Array(0);

    let psz = 0;
    // Skip and count leading '1's.
    let zeroes = 0;
    let length = 0;
    while (source[psz] === this.leader) {
      zeroes++;
      psz++;
    }
    // Allocate enough space in big-endian base256 representation.
    const size = ((source.length - psz) * this.factor + 1) >>> 0; // log(58) / log(256), rounded up.
    const b256 = new Uint8Array(size);
    // Process the characters.
    while (source[psz]) {
      // Decode character
      let carry = this.baseMap[source.charCodeAt(psz)];
      // Invalid character
      if (carry === 255) return;

      let i = 0;
      for (
        let it3 = size - 1;
        (carry !== 0 || i < length) && it3 !== -1;
        it3--, i++
      ) {
        carry += (this.base * b256[it3]) >>> 0;
        b256[it3] = carry % 256 >>> 0;
        carry = (carry / 256) >>> 0;
      }
      if (carry !== 0) throw new Error("Non-zero carry");

      length = i;
      psz++;
    }
    // Skip leading zeroes in b256.
    let it4 = size - length;
    while (it4 !== size && b256[it4] === 0) it4++;

    const vch = new Uint8Array(zeroes + (size - it4));
    let j = zeroes;
    while (it4 !== size) vch[j++] = b256[it4++];

    return vch;
  }

  decode(source: string) {
    const buffer = this.decodeUnsafe(source);
    if (buffer) return buffer;
    throw new Error("Non-base" + this.base + " character");
  }
}

export class CryptoUtils {
  private static readonly b58encoder = new BaseEncoder(BASE_ALPHABET.BASE58);
  private static readonly logger = new MiniLogger(CryptoUtils.name);
  private constructor() {}

  static fabricIdFromCertificate(certificate: string) {
    this.logger.debug(stringFormat("Parsing certificate: {0}", certificate));
    const cert = new x509.X509Certificate(certificate);
    const { subject, issuer } = cert;
    this.logger.debug(
      stringFormat(
        "Certificate parsed with subject {0} and issuer {1}",
        subject,
        issuer
      )
    );
    return `x509::/${subject.replaceAll(", ", "/")}::/${issuer.replaceAll(", ", "/")}`;
  }

  static encode(str: string): string {
    return this.b58encoder.encode(str);
  }
  static decode(str: string): string {
    const decoded = this.b58encoder.decode(str);
    const result = new TextDecoder().decode(decoded);
    return result;
  }

  static stringToArrayBuffer(str: string) {
    const buf = new ArrayBuffer(str.length);
    const bufView = new Uint8Array(buf);
    for (let i = 0, strLen = str.length; i < strLen; i++) {
      bufView[i] = str.charCodeAt(i);
    }
    return buf;
  }

  private static async extractKey(
    type: "private" | "public",
    pem: Buffer | string,
    usages?: any[]
  ) {
    const subtle = crypto.subtle;

    const str = pem
      .toString("utf8")
      .replace(
        new RegExp(`-----BEGIN (${type.toUpperCase()} KEY|CERTIFICATE)-----`),
        ""
      )
      .replaceAll("\n", "")
      .replace(
        new RegExp(`-----END (${type.toUpperCase()} KEY|CERTIFICATE)-----`),
        ""
      );
    const decoded = Buffer.from(str, "base64").toString("binary");
    const binaryDer = this.stringToArrayBuffer(decoded);
    const key = await subtle.importKey(
      "pkcs8",
      binaryDer,
      {
        name: "ECDSA",
        namedCurve: "P-256",
      },
      true,
      usages ? usages : ["sign"]
    );

    return key;
  }

  static async extractPrivateKey(pem: Buffer | string, usages?: any[]) {
    return this.extractKey("private", pem, usages);
  }

  static async extractPublicKey(pem: Buffer | string, usages?: any[]) {
    return this.extractKey("public", pem, usages);
  }

  static async sign(privateKey: string, data: Buffer): Promise<string> {
    const key = await this.extractPrivateKey(privateKey);
    const buff = (await crypto.subtle.sign(
      {
        name: "ECDSA",
        hash: "SHA-256",
      },
      key,
      data
    )) as ArrayBuffer;

    return Array.from(new Uint8Array(buff))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");
  }

  static async verify(
    certificate: string,
    signature: Buffer | string,
    data: Buffer | string
  ): Promise<boolean> {
    const cert = new x509.X509Certificate(certificate);
    const key = await cert.publicKey.export();
    signature = (
      typeof signature === "string" ? Buffer.from(signature, "hex") : signature
    ) as Buffer;
    data = (typeof data === "string" ? Buffer.from(data) : data) as Buffer;
    return crypto.subtle.verify(
      {
        name: "ECDSA",
        hash: "SHA-256",
      },
      key,
      signature,
      data
    );
  }

  static async encrypt(certificate: string, data: string | Buffer) {
    const cert = new x509.X509Certificate(certificate);
    const key = await cert.publicKey.export();
    data = (typeof data === "string" ? Buffer.from(data) : data) as Buffer;
    const buff = await this.getSubtleCrypto().encrypt(
      {
        name: "ECDSA",
      },
      key,
      data
    );

    return Array.from(new Uint8Array(buff))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");
  }

  private static getSubtleCrypto() {
    return isBrowser()
      ? (globalThis as any).window.crypto.subtle
      : crypto.subtle;
  }

  static async decrypt(privateKey: string, data: string | Buffer) {
    const key = await this.extractPrivateKey(privateKey);
    data = (
      typeof data === "string" ? Buffer.from(data, "hex") : data
    ) as Buffer;
    return this.getSubtleCrypto().decrypt(
      {
        name: "ECDSA",
      },
      key,
      data
    );
  }

  /**
   * @summary Util function to get a random master key
   *
   * @description If data is not passed, a random ArrayBuffer will be generated
   *
   * @param {ArrayBuffer} data encrytion data
   *
   * @function getMaster
   */
  static async getMaster(data?: ArrayBuffer): Promise<keyObject> {
    const textEncoder = new TextEncoder();
    if (data === undefined) {
      const genGenesis = crypto.randomUUID();
      data = textEncoder.encode(genGenesis).buffer;
    }

    const importedKey = await this.getSubtleCrypto().importKey(
      "raw",
      data,
      CRYPTO.KEY_ALGORYTHM as string,
      false,
      ["deriveBits"]
    );

    return {
      key: importedKey,
      iv: data!,
    };
  }

  /**
   * @summary Util function to derive a key from another key
   *
   * @param {string} salt
   * @param {CryptoKey} key Original key
   *
   * @function getDerivationKey
   */
  static async getDerivationKey(salt: string, key: CryptoKey) {
    const textEncoder = new TextEncoder();
    const saltBuffer = textEncoder.encode(salt);
    const saltHashed = await this.getSubtleCrypto().digest(
      "SHA-256",
      saltBuffer
    );
    const params = {
      name: CRYPTO.KEY_ALGORYTHM as string,
      hash: CRYPTO.HASH,
      salt: saltHashed,
      iterations: CRYPTO.ITERATIONS,
    };
    const derivation = await this.getSubtleCrypto().deriveBits(
      params,
      key,
      CRYPTO.KEYLENGTH * 8
    );
    return this.getKey(derivation);
  }

  /**
   * @summary Util function to get the key and IV from the CrytoKey array
   *
   * @param {ArrayBuffer} derivation
   *
   * @function getKey
   */
  static async getKey(derivation: ArrayBuffer) {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const ivlen = 16;
    const keylen = 32;
    const derivedKey = derivation.slice(0, keylen);
    const iv = derivation.slice(keylen);
    const importedEncryptionKey = await this.getSubtleCrypto().importKey(
      "raw",
      derivedKey,
      { name: CRYPTO.ALGORYTHM as string },
      false,
      ["encrypt", "decrypt"]
    );
    return {
      key: importedEncryptionKey,
      iv: iv,
    };
  }

  /**
   * @summary Util function to decrypt data
   *
   * @param {string} text
   * @param {keyObject} keyObject
   *
   * @function encrypt
   */
  static async encryptPin(
    text: string,
    keyObject: keyObject
  ): Promise<ArrayBuffer> {
    const textEncoder = new TextEncoder();
    const textBuffer = textEncoder.encode(text);
    const encryptedText = await this.getSubtleCrypto().encrypt(
      { name: CRYPTO.ALGORYTHM as string, iv: keyObject.iv },
      keyObject.key,
      textBuffer
    );
    return encryptedText;
  }

  /**
   * @summary Util function to decrypt data
   *
   * @param {BufferSource} encryptedText
   * @param {keyObject} keyObject
   *
   * @function decrypt
   */
  static async decryptPin(
    encryptedText: ArrayBuffer,
    keyObject: keyObject
  ): Promise<string> {
    const textDecoder = new TextDecoder();
    const decryptedText = await this.getSubtleCrypto().decrypt(
      { name: CRYPTO.ALGORYTHM as string, iv: keyObject.iv },
      keyObject.key,
      encryptedText
    );
    return textDecoder.decode(decryptedText);
  }
}