Source

client/services/FabricEnrollmentService.ts

import FabricCAServices from "fabric-ca-client";
import {
  AffiliationService,
  IdentityService,
  IEnrollResponse,
  IRegisterRequest,
  IServiceResponse,
  TLSOptions,
} from "fabric-ca-client";
import { User } from "fabric-common";
import { CAConfig, Credentials } from "../../shared/types";
import { Identity } from "../../shared/model/Identity";
import { AuthorizationError } from "@decaf-ts/core";
import {
  ConflictError,
  InternalError,
  NotFoundError,
} from "@decaf-ts/db-decorators";
import { CoreUtils } from "../../shared/utils";
import { CA_ROLE } from "./constants";
import { CryptoUtils } from "../../shared/crypto";
import {
  CertificateResponse,
  FabricIdentity,
  GetCertificatesRequest,
  IdentityResponse,
} from "../../shared/fabric-types";
import { RegistrationError } from "../../shared/errors";
import { LoggedClass, Logging } from "@decaf-ts/logging";

/**
 * @description Hyperledger Fabric CA identity types.
 * @summary Enumerates the supported identity types recognized by Fabric CA for registration and identity management.
 * @enum {string}
 * @readonly
 * @memberOf module:for-fabric.client
 */
export enum HFCAIdentityType {
  PEER = "peer",
  ORDERER = "orderer",
  CLIENT = "client",
  USER = "user",
  ADMIN = "admin",
}
/**
 * @description Key/value attribute used during CA registration.
 * @summary Represents an attribute entry that can be attached to a Fabric CA identity during registration, optionally marking it for inclusion in ecert.
 * @interface IKeyValueAttribute
 * @template T
 * @param {string} name - Attribute name.
 * @param {string} value - Attribute value.
 * @param {boolean} [ecert] - Whether the attribute should be included in the enrollment certificate (ECert).
 * @memberOf module:for-fabric.client
 */
export interface IKeyValueAttribute {
  name: string;
  value: string;
  ecert?: boolean;
}

/**
 * @description Standard Fabric CA identity attribute keys.
 * @summary Enumerates well-known Fabric CA attribute keys that can be assigned to identities for delegations and permissions.
 * @enum {string}
 * @readonly
 * @memberOf module:for-fabric.client
 */
export enum HFCAIdentityAttributes {
  HFREGISTRARROLES = "hf.Registrar.Roles",
  HFREGISTRARDELEGATEROLES = "hf.Registrar.DelegateRoles",
  HFREGISTRARATTRIBUTES = "hf.Registrar.Attributes",
  HFINTERMEDIATECA = "hf.IntermediateCA",
  HFREVOKER = "hf.Revoker",
  HFAFFILIATIONMGR = "hf.AffiliationMgr",
  HFGENCRL = "hf.GenCRL",
}

/**
 * @description Service wrapper for interacting with a Fabric CA.
 * @summary Provides high-level operations for managing identities against a Hyperledger Fabric Certificate Authority, including registration, enrollment, revocation, and administrative queries. Encapsulates lower-level Fabric CA client calls with consistent logging and error mapping.
 * @param {CAConfig} caConfig - Connection and TLS configuration for the target CA.
 * @class FabricEnrollmentService
 * @example
 * // Register and enroll a new user
 * const svc = new FabricEnrollmentService({
 *   url: 'https://localhost:7054',
 *   caName: 'Org1CA',
 *   tls: { trustedRoots: ['/path/to/ca.pem'], verify: false },
 *   caCert: '/path/to/admin/certDir',
 *   caKey: '/path/to/admin/keyDir'
 * });
 * await svc.register({ userName: 'alice', password: 's3cr3t' }, false, 'org1.department1', CA_ROLE.USER);
 * const id = await svc.enroll('alice', 's3cr3t');
 * @mermaid
 * sequenceDiagram
 *   autonumber
 *   participant App
 *   participant Svc as FabricEnrollmentService
 *   participant CA as Fabric CA
 *   App->>Svc: register(credentials, ...)
 *   Svc->>CA: register(request, adminUser)
 *   CA-->>Svc: enrollmentSecret
 *   Svc-->>App: secret
 *   App->>Svc: enroll(enrollmentId, secret)
 *   Svc->>CA: enroll({enrollmentID, secret})
 *   CA-->>Svc: certificates
 *   Svc-->>App: Identity
 */
export class FabricEnrollmentService extends LoggedClass {
  private ca?: FabricCAServices;

  private certificateService?: any;

  private affiliationService?: AffiliationService;

  private identityService?: IdentityService;

  private client?: any;

  private user?: User;

  constructor(private caConfig: CAConfig) {
    CoreUtils.getCryptoSuite(
      caConfig.hsm
        ? {
            software: false,
            lib: caConfig.hsm.library,
            slot: caConfig.hsm.slot,
            label: caConfig.hsm.tokenLabel,
            pin: String(caConfig.hsm.pin),
          }
        : undefined
    );
    super();
  }

  protected async User(): Promise<User> {
    if (this.user) return this.user;
    const { caName, caCert, caKey, url, hsm } = this.caConfig;
    const log = this.log.for(this.User);
    log.debug(`Creating CA user for ${caName} at ${url}`);
    log.debug(`Retrieving CA certificate from ${caCert}`);
    const certificate = await CoreUtils.getFirstDirFileNameContent(caCert);
    let key: string | undefined;
    if (!hsm) {
      if (!caKey) {
        throw new InternalError(
          `Missing caKey configuration for CA ${caName}. Provide a key directory or configure HSM support.`
        );
      }
      log.debug(`Retrieving CA key from ${caKey}`);
      key = await CoreUtils.getFirstDirFileNameContent(caKey);
    } else {
      log.debug(
        `Using HSM configuration for CA ${caName} with library ${hsm.library}`
      );
    }
    log.debug(`Loading Admin user for ca ${caName}`);
    this.user = await CoreUtils.getCAUser("admin", key, certificate, caName, {
      hsm,
    });
    return this.user;
  }

  protected async CA(): Promise<FabricCAServices> {
    if (this.ca) return this.ca;
    const log = this.log.for(this.CA);
    const { url, tls, caName } = this.caConfig;

    // FOR Some Reason the verification fails need to investigate this works for now
    // eslint-disable-next-line prefer-const
    let { trustedRoots, verify } = tls as TLSOptions;

    verify = false;

    const root = (trustedRoots as string[])[0] as string;
    log.debug(`Retrieving CA certificate from ${root}. cwd: ${process.cwd()}`);

    const certificate = await CoreUtils.getFileContent(root);
    log.debug(`Creating CA Client for CA ${caName} under ${url}`);
    this.ca = new FabricCAServices(
      url,
      {
        trustedRoots: Buffer.from(certificate),
        verify,
      } as TLSOptions,
      caName
    );
    return this.ca;
  }

  protected async Client(): Promise<{ newCertificateService: any }> {
    if (this.client) return this.client;
    const ca = await this.CA();
    this.client = (ca as any)["_FabricCAServices"];
    return this.client;
  }

  protected async Certificate() {
    if (!this.certificateService)
      this.certificateService = (await this.Client()).newCertificateService();
    return this.certificateService;
  }

  protected async Affiliations() {
    if (!this.affiliationService)
      this.affiliationService = (await this.CA()).newAffiliationService();
    return this.affiliationService;
  }

  protected async Identities() {
    if (!this.identityService)
      this.identityService = (await this.CA()).newIdentityService();
    return this.identityService;
  }

  /**
   * @description Retrieve certificates from the CA.
   * @summary Calls the CA certificate service to list certificates, optionally mapping to PEM strings only.
   * @param {GetCertificatesRequest} [request] - Optional filter request for certificate lookup.
   * @param {boolean} [doMap=true] - When true, returns array of PEM strings; otherwise returns full response object.
   * @return {Promise<string[] | CertificateResponse>} Array of PEM strings or the full certificate response.
   */
  async getCertificates(
    request?: GetCertificatesRequest,
    doMap = true
  ): Promise<string[] | CertificateResponse> {
    const certificateService = await this.Certificate();
    const user = await this.User();
    const log = this.log.for(this.getCertificates);
    log.debug(
      `Retrieving certificates${request ? ` for ${request.id}` : ""} for CA ${this.caConfig.caName}`
    );
    const response: CertificateResponse = (
      await certificateService.getCertificates(request || {}, user)
    ).result;
    log.debug(
      `Found ${response.certs.length} certificates: ${JSON.stringify(response)}`
    );
    return doMap ? response.certs.map((c) => c.PEM) : response;
  }

  /**
   * @description List identities registered in the CA.
   * @summary Queries the CA identity service to fetch all identities and returns the list as FabricIdentity objects.
   * @return {Promise<FabricIdentity[]>} The list of identities registered in the CA.
   */
  async getIdentities(): Promise<FabricIdentity[]> {
    const identitiesService = await this.Identities();
    const log = this.log.for(this.getIdentities);
    log.debug(`Retrieving Identities under CA ${this.caConfig.caName}`);
    const response: IdentityResponse = (
      await identitiesService.getAll(await this.User())
    ).result;
    log.debug(
      `Found ${response.identities.length} Identities: ${JSON.stringify(response)}`
    );
    return response.identities;
  }

  protected parseError(e: Error) {
    const regexp = /.*code:\s(\d+).*?message:\s["'](.+)["']/gs;
    const match = regexp.exec(e.message);
    if (!match) return new RegistrationError(e);
    const [, code, message] = match;
    switch (code) {
      case "74":
      case "71":
        return new ConflictError(message);
      case "20":
        return new AuthorizationError(message);
      default:
        return new RegistrationError(message);
    }
  }

  /**
   * @description Retrieve affiliations from the CA.
   * @summary Queries the CA for the list of affiliations available under the configured CA.
   * @return {string} The affiliations result payload.
   */
  async getAffiliations() {
    const affiliationService = await this.Affiliations();
    const log = this.log.for(this.getAffiliations);
    log.debug(`Retrieving Affiliations under CA ${this.caConfig.caName}`);
    const response = (await affiliationService.getAll(await this.User()))
      .result;
    log.debug(
      `Found ${response.a.length} Affiliations: ${JSON.stringify(response)}`
    );
    return response;
  }

  /**
   * @description Read identity details from the CA by enrollment ID.
   * @summary Retrieves and validates a single identity, throwing NotFoundError when missing.
   * @param {string} enrollmentId - Enrollment ID to lookup.
   * @return {Promise<FabricIdentity>} The identity details stored in the CA.
   */
  async read(enrollmentId: string) {
    const ca = await this.CA();
    const user = await this.User();
    let result: IServiceResponse;
    try {
      result = await ca.newIdentityService().getOne(enrollmentId, user);
    } catch (e: any) {
      throw new NotFoundError(
        `Couldn't find enrollment with id ${enrollmentId}: ${e}`
      );
    }

    if (!result.success)
      throw new NotFoundError(
        `Couldn't find enrollment with id ${enrollmentId}: ${result.errors.join("\n")}`
      );

    return result.result as FabricIdentity;
  }

  /**
   * @description Register a new identity with the CA.
   * @summary Submits a registration request for a new enrollment ID, returning the enrollment secret upon success.
   * @param {Credentials} model - Credentials containing userName and password for the new identity.
   * @param {boolean} [isSuperUser=false] - Whether to register the identity as a super user.
   * @param {string} [affiliation=""] - Affiliation string (e.g., org1.department1).
   * @param {CA_ROLE | string} [userRole] - Role to assign to the identity.
   * @param {IKeyValueAttribute} [attrs] - Optional attributes to attach to the identity.
   * @param {number} [maxEnrollments] - Maximum number of enrollments allowed for the identity.
   * @return {Promise<string>} The enrollment secret for the registered identity.
   */
  async register(
    model: Credentials,
    isSuperUser: boolean = false,
    affiliation: string = "",
    userRole?: CA_ROLE | string,
    attrs?: IKeyValueAttribute,
    maxEnrollments?: number
  ): Promise<string> {
    let registration: string;
    const log = this.log.for(this.register);
    try {
      const { userName, password } = model;
      const ca = await this.CA();
      const user = await this.User();
      const props = {
        enrollmentID: userName as string,
        enrollmentSecret: password,
        affiliation: affiliation,
        userRole: userRole,
        attrs: attrs,
        maxEnrollments: maxEnrollments,
      } as IRegisterRequest;
      registration = await ca.register(props, user);
      log.info(
        `Registration for ${userName} created with user type ${userRole ?? "Undefined Role"} ${isSuperUser ? "as super user" : ""}`
      );
    } catch (e: any) {
      throw this.parseError(e);
    }
    return registration;
  }

  protected static identityFromEnrollment(
    enrollment: IEnrollResponse,
    mspId: string
  ): Identity {
    const { certificate, key, rootCertificate } = enrollment;
    const log = Logging.for(FabricEnrollmentService, {}).for(
      this.identityFromEnrollment
    );
    log.debug(
      `Generating Identity from certificate ${certificate} in msp ${mspId}`
    );
    const clientId = CryptoUtils.fabricIdFromCertificate(certificate);
    const id = CryptoUtils.encode(clientId);
    log.debug(`Identity ${clientId} and encodedId ${id}`);
    const now = new Date();
    return new Identity({
      id: id,
      credentials: {
        id: id,
        certificate: certificate,
        privateKey: key.toBytes(),
        rootCertificate: rootCertificate,
        createdOn: now,
        updatedOn: now,
      },
      mspId: mspId,
      createdOn: now,
      updatedOn: now,
    });
  }

  /**
   * @description Enroll an identity with the CA using a registration secret.
   * @summary Exchanges the enrollment ID and secret for certificates, returning a constructed Identity model.
   * @param {string} enrollmentId - Enrollment ID to enroll.
   * @param {string} registration - Enrollment secret returned at registration time.
   * @return {Promise<Identity>} The enrolled identity object with credentials.
   */
  async enroll(enrollmentId: string, registration: string) {
    let identity: Identity;
    const log = this.log.for(this.enroll);
    try {
      const ca = await this.CA();
      log.debug(`Enrolling ${enrollmentId}`);
      const enrollment: IEnrollResponse = await ca.enroll({
        enrollmentID: enrollmentId,
        enrollmentSecret: registration,
      });
      identity = FabricEnrollmentService.identityFromEnrollment(
        enrollment,
        this.caConfig.caName
      );
      log.info(
        `Successfully enrolled ${enrollmentId} under ${this.caConfig.caName} as ${identity.id}`
      );
    } catch (e: any) {
      throw this.parseError(e);
    }
    return identity;
  }

  /**
   * @description Register and enroll a new identity in one step.
   * @summary Registers a new enrollment ID with the CA and immediately exchanges the secret to enroll, returning the created Identity.
   * @param {Credentials} model - Credentials for the new identity containing userName and password.
   * @param {boolean} [isSuperUser=false] - Whether to register the identity as a super user.
   * @param {string} [affiliation=""] - Affiliation string (e.g., org1.department1).
   * @param {CA_ROLE | string} [userRole] - Role to assign to the identity.
   * @param {IKeyValueAttribute} [attrs] - Optional attributes to attach to the identity.
   * @param {number} [maxEnrollments] - Maximum number of enrollments allowed for the identity.
   * @return {Promise<Identity>} The enrolled identity.
   */
  async registerAndEnroll(
    model: Credentials,
    isSuperUser: boolean = false,
    affiliation: string = "",
    userRole?: CA_ROLE | string,
    attrs?: IKeyValueAttribute,
    maxEnrollments?: number
  ): Promise<Identity> {
    const registration = await this.register(
      model,
      isSuperUser,
      affiliation,
      userRole,
      attrs,
      maxEnrollments
    );
    const { userName } = model;
    return this.enroll(userName as string, registration);
  }

  /**
   * Revokes the enrollment of an identity with the specified enrollment ID.
   *
   * @param enrollmentId - The enrollment ID of the identity to be revoked.
   *
   * @returns A Promise that resolves to the result of the revocation operation.
   *
   * @throws {NotFoundError} If the enrollment with the specified ID does not exist.
   * @throws {InternalError} If there is an error during the revocation process.
   */
  async revoke(enrollmentId: string) {
    const ca = await this.CA();
    const user = await this.User();
    const identity = await this.read(enrollmentId);
    if (!identity)
      throw new NotFoundError(
        `Could not find enrollment with id ${enrollmentId}`
      );
    let result: IServiceResponse;
    try {
      result = await ca.revoke(
        { enrollmentID: identity.id, reason: "User Deletation" },
        user
      );
    } catch (e: unknown) {
      throw new InternalError(
        `Could not revoke enrollment with id ${enrollmentId}: ${e}`
      );
    }
    if (!result.success)
      throw new InternalError(
        `Could not revoke enrollment with id ${enrollmentId}: ${result.errors.join("\n")}`
      );
    return result;
  }
}