/ scripts / root-ca-retriever / dod-certs.ts
dod-certs.ts
  1  /**
  2   * Copyright 2025 Defense Unicorns
  3   * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
  4   */
  5  
  6  import * as https from "https";
  7  import * as fs from "fs";
  8  import * as path from "path";
  9  import * as crypto from "crypto";
 10  import AdmZip from "adm-zip";
 11  
 12  export interface DoDCert {
 13    filepath: string; // Folder path within the zip
 14    filename: string; // Certificate file name
 15    content: string; // Certificate content
 16    organization: string; // Organization name (folder name)
 17  }
 18  
 19  const DOD_CERTS_ZIP_NAME = "unclass-dod_approved_external_pkis_trust_chains.zip";
 20  const DOD_CERTS_URL = `https://dl.dod.cyber.mil/wp-content/uploads/pki-pke/zip/${DOD_CERTS_ZIP_NAME}`;
 21  const TARGET_DOD_CERT_DIR = "dod"; // subdirectory in certs directory to put DoD certs
 22  
 23  /**
 24   * Retrieves DoD certificates from the official DoD PKI repository, downloads the ZIP archive,
 25   * extracts certificates to the specified directory, and cleans up the temporary ZIP file
 26   * @param dirName - The directory path where certificates should be extracted
 27   * @throws {Error} When download fails, extraction fails, or file operations fail
 28   * @returns Promise that resolves when download and extraction are complete
 29   */
 30  export async function retrieveDoDCertificates(dirName: string) {
 31    console.log("Starting DoD certificate download...");
 32    const downloadOutputFilePath = `${dirName}/${DOD_CERTS_ZIP_NAME}`;
 33    const fileStream = fs.createWriteStream(downloadOutputFilePath);
 34    const outputDir = `${dirName}/${TARGET_DOD_CERT_DIR}`;
 35  
 36    // If the directory does not exist, create it
 37    if (!fs.existsSync(dirName)) {
 38      fs.mkdirSync(dirName);
 39      console.log(`Created directory: ${dirName}`);
 40    }
 41  
 42    // Download, extract, and clean up the DoD certificates zip file
 43    return new Promise<void>((resolve, reject) => {
 44      console.log(`Downloading from: ${DOD_CERTS_URL}`);
 45      https
 46        .get(DOD_CERTS_URL, response => {
 47          if (response.statusCode !== 200) {
 48            reject(new Error(`Failed to get DoD certificates. Status code: ${response.statusCode}`));
 49            return;
 50          }
 51  
 52          response.pipe(fileStream);
 53  
 54          fileStream.on("finish", () => {
 55            fileStream.close();
 56            console.log("Download complete, extracting certificates...");
 57  
 58            // Extract the zip file
 59            try {
 60              const zip = new AdmZip(downloadOutputFilePath);
 61              zip.extractAllTo(outputDir, true);
 62              console.log(`Extracted certificates to: ${outputDir}`);
 63  
 64              fs.unlinkSync(downloadOutputFilePath);
 65              console.log("Certificate retrieval complete");
 66              resolve();
 67            } catch (err) {
 68              reject(new Error(`Failed to extract DoD certificates: ${err}`));
 69            }
 70          });
 71        })
 72        .on("error", err => {
 73          fs.unlink(downloadOutputFilePath, () => {
 74            reject(err);
 75          });
 76        });
 77    });
 78  }
 79  
 80  /**
 81   * Reads the unzipped directory structure for DoD certificates and inventories them
 82   * into an array of DoDCert objects. Validates each certificate format using Node.js crypto.
 83   * @param dirName - The base directory containing the extracted DoD certificates
 84   * @param targetDir - The target subdirectory name (defaults to TARGET_DOD_CERT_DIR for production)
 85   * @returns Promise that resolves to an array of DoDCert objects with validated certificates
 86   * @throws {Error} When directory traversal fails or certificate validation fails
 87   */
 88  export async function inventoryDoDCertificates(
 89    dirName: string,
 90    targetDir: string = TARGET_DOD_CERT_DIR,
 91  ): Promise<DoDCert[]> {
 92    const certs: DoDCert[] = [];
 93    const certDir = path.join(dirName, targetDir);
 94  
 95    try {
 96      // Recursive function to walk through directories
 97      const walkDirectory = async (currentDir: string): Promise<void> => {
 98        const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
 99  
100        for (const entry of entries) {
101          const fullPath = path.join(currentDir, entry.name);
102  
103          if (entry.isDirectory()) {
104            await walkDirectory(fullPath);
105          } else if (entry.isFile()) {
106            const ext = path.extname(entry.name).toLowerCase();
107            if (ext === ".cer") {
108              // Read as binary to handle both PEM and DER formats
109              const binaryContent = await fs.promises.readFile(fullPath);
110  
111              // Validate and convert to PEM using crypto library
112              let content: string;
113              try {
114                const cert = new crypto.X509Certificate(binaryContent);
115                content = cert.toString(); // This returns PEM format
116              } catch (error) {
117                throw new Error(
118                  `Invalid certificate ${entry.name}: ${error instanceof Error ? error.message : String(error)}`,
119                );
120              }
121  
122              // Get the organization folder - find the folder directly under the version directory
123              const relativePath = path.relative(certDir, fullPath);
124              const pathParts = relativePath.split(path.sep);
125              const organization = pathParts[1] || "Unknown"; // Skip version folder [0], take org folder [1]
126  
127              const cert: DoDCert = {
128                filepath: path.dirname(fullPath),
129                filename: entry.name,
130                content: content,
131                organization: organization,
132              };
133              certs.push(cert);
134            }
135          }
136        }
137      };
138  
139      await walkDirectory(certDir);
140      return certs;
141    } catch (error) {
142      throw new Error(`Failed to inventory certificates: ${error}`);
143    }
144  }
145  
146  /**
147   * Compares two arrays of DoDCert objects and returns differences including
148   * added, removed, and modified certificates based on file path and content
149   * @param existing - Array of existing DoD certificates
150   * @param downloaded - Array of newly downloaded DoD certificates
151   * @returns Object containing arrays of added, removed, and modified certificates
152   */
153  export function diffDoDCerts(existing: DoDCert[], downloaded: DoDCert[]) {
154    // Strip base path and use relative path from /dod/ onwards as key
155    const getKey = (cert: DoDCert) => {
156      const dodIndex = cert.filepath.indexOf(`/${TARGET_DOD_CERT_DIR}/`);
157      const relativePath = cert.filepath.substring(dodIndex);
158      return `${relativePath}/${cert.filename}`;
159    };
160  
161    const existingMap = new Map(existing.map(cert => [getKey(cert), cert]));
162    const downloadedMap = new Map(downloaded.map(cert => [getKey(cert), cert]));
163  
164    const added = downloaded.filter(cert => !existingMap.has(getKey(cert)));
165    const removed = existing.filter(cert => !downloadedMap.has(getKey(cert)));
166    const modified = downloaded
167      .filter(cert => {
168        const key = getKey(cert);
169        const existingCert = existingMap.get(key);
170        return existingCert && existingCert.content !== cert.content;
171      })
172      .map(cert => ({ old: existingMap.get(getKey(cert))!, new: cert }));
173  
174    return { added, removed, modified };
175  }