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 }