1// Script to update certdata.txt from NSS. 2import { execFileSync } from 'node:child_process'; 3import { randomUUID } from 'node:crypto'; 4import { createWriteStream } from 'node:fs'; 5import { basename, join, relative } from 'node:path'; 6import { Readable } from 'node:stream'; 7import { pipeline } from 'node:stream/promises'; 8import { fileURLToPath } from 'node:url'; 9import { parseArgs } from 'node:util'; 10 11// Constants for NSS release metadata. 12const kNSSVersion = 'version'; 13const kNSSDate = 'date'; 14const kFirefoxVersion = 'firefoxVersion'; 15const kFirefoxDate = 'firefoxDate'; 16 17const __filename = fileURLToPath(import.meta.url); 18const now = new Date(); 19 20const formatDate = (d) => { 21 const iso = d.toISOString(); 22 return iso.substring(0, iso.indexOf('T')); 23}; 24 25const getCertdataURL = (version) => { 26 const tag = `NSS_${version.replaceAll('.', '_')}_RTM`; 27 const certdataURL = `https://hg.mozilla.org/projects/nss/raw-file/${tag}/lib/ckfw/builtins/certdata.txt`; 28 return certdataURL; 29}; 30 31const normalizeTD = (text) => { 32 // Remove whitespace and any HTML tags. 33 return text?.trim().replace(/<.*?>/g, ''); 34}; 35const getReleases = (text) => { 36 const releases = []; 37 const tableRE = /<table [^>]+>([\S\s]*?)<\/table>/g; 38 const tableRowRE = /<tr ?[^>]*>([\S\s]*?)<\/tr>/g; 39 const tableHeaderRE = /<th ?[^>]*>([\S\s]*?)<\/th>/g; 40 const tableDataRE = /<td ?[^>]*>([\S\s]*?)<\/td>/g; 41 for (const table of text.matchAll(tableRE)) { 42 const columns = {}; 43 const matches = table[1].matchAll(tableRowRE); 44 // First row has the table header. 45 let row = matches.next(); 46 if (row.done) { 47 continue; 48 } 49 const headers = Array.from(row.value[1].matchAll(tableHeaderRE), (m) => m[1]); 50 if (headers.length > 0) { 51 for (let i = 0; i < headers.length; i++) { 52 if (/NSS version/i.test(headers[i])) { 53 columns[kNSSVersion] = i; 54 } else if (/Release.*from branch/i.test(headers[i])) { 55 columns[kNSSDate] = i; 56 } else if (/Firefox version/i.test(headers[i])) { 57 columns[kFirefoxVersion] = i; 58 } else if (/Firefox release date/i.test(headers[i])) { 59 columns[kFirefoxDate] = i; 60 } 61 } 62 } 63 // Filter out "NSS Certificate bugs" table. 64 if (columns[kNSSDate] === undefined) { 65 continue; 66 } 67 // Scrape releases. 68 row = matches.next(); 69 while (!row.done) { 70 const cells = Array.from(row.value[1].matchAll(tableDataRE), (m) => m[1]); 71 const release = {}; 72 release[kNSSVersion] = normalizeTD(cells[columns[kNSSVersion]]); 73 release[kNSSDate] = new Date(normalizeTD(cells[columns[kNSSDate]])); 74 release[kFirefoxVersion] = normalizeTD(cells[columns[kFirefoxVersion]]); 75 release[kFirefoxDate] = new Date(normalizeTD(cells[columns[kFirefoxDate]])); 76 releases.push(release); 77 row = matches.next(); 78 } 79 } 80 return releases; 81}; 82 83const getLatestVersion = async (releases) => { 84 const arrayNumberSortDescending = (x, y, i) => { 85 if (x[i] === undefined && y[i] === undefined) { 86 return 0; 87 } else if (x[i] === y[i]) { 88 return arrayNumberSortDescending(x, y, i + 1); 89 } 90 return (y[i] ?? 0) - (x[i] ?? 0); 91 }; 92 const extractVersion = (t) => { 93 return t[kNSSVersion].split('.').map((n) => parseInt(n)); 94 }; 95 const releaseSorter = (x, y) => { 96 return arrayNumberSortDescending(extractVersion(x), extractVersion(y), 0); 97 }; 98 // Return the most recent certadata.txt that exists on the server. 99 const sortedReleases = releases.sort(releaseSorter).filter(pastRelease); 100 for (const candidate of sortedReleases) { 101 const candidateURL = getCertdataURL(candidate[kNSSVersion]); 102 if (values.verbose) { 103 console.log(`Trying ${candidateURL}`); 104 } 105 const response = await fetch(candidateURL, { method: 'HEAD' }); 106 if (response.ok) { 107 return candidate[kNSSVersion]; 108 } 109 } 110}; 111 112const pastRelease = (r) => { 113 return r[kNSSDate] < now; 114}; 115 116const options = { 117 help: { 118 type: 'boolean', 119 }, 120 file: { 121 short: 'f', 122 type: 'string', 123 }, 124 verbose: { 125 short: 'v', 126 type: 'boolean', 127 }, 128}; 129const { 130 positionals, 131 values, 132} = parseArgs({ 133 allowPositionals: true, 134 options, 135}); 136 137if (values.help) { 138 console.log(`Usage: ${basename(__filename)} [OPTION]... [VERSION]...`); 139 console.log(); 140 console.log('Updates certdata.txt to NSS VERSION (most recent release by default).'); 141 console.log(''); 142 console.log(' -f, --file=FILE writes a commit message reflecting the change to the'); 143 console.log(' specified FILE'); 144 console.log(' -v, --verbose writes progress to stdout'); 145 console.log(' --help display this help and exit'); 146 process.exit(0); 147} 148 149const scheduleURL = 'https://wiki.mozilla.org/NSS:Release_Versions'; 150if (values.verbose) { 151 console.log(`Fetching NSS release schedule from ${scheduleURL}`); 152} 153const schedule = await fetch(scheduleURL); 154if (!schedule.ok) { 155 console.error(`Failed to fetch ${scheduleURL}: ${schedule.status}: ${schedule.statusText}`); 156 process.exit(-1); 157} 158const scheduleText = await schedule.text(); 159const nssReleases = getReleases(scheduleText); 160 161// Retrieve metadata for the NSS release being updated to. 162const version = positionals[0] ?? await getLatestVersion(nssReleases); 163const release = nssReleases.find((r) => { 164 return new RegExp(`^${version.replace('.', '\\.')}\\b`).test(r[kNSSVersion]); 165}); 166if (!pastRelease(release)) { 167 console.warn(`Warning: NSS ${version} is not due to be released until ${formatDate(release[kNSSDate])}`); 168} 169if (values.verbose) { 170 console.log('Found NSS version:'); 171 console.log(release); 172} 173 174// Fetch certdata.txt and overwrite the local copy. 175const certdataURL = getCertdataURL(version); 176if (values.verbose) { 177 console.log(`Fetching ${certdataURL}`); 178} 179const checkoutDir = join(__filename, '..', '..', '..'); 180const certdata = await fetch(certdataURL); 181const certdataFile = join(checkoutDir, 'tools', 'certdata.txt'); 182if (!certdata.ok) { 183 console.error(`Failed to fetch ${certdataURL}: ${certdata.status}: ${certdata.statusText}`); 184 process.exit(-1); 185} 186if (values.verbose) { 187 console.log(`Writing ${certdataFile}`); 188} 189await pipeline(certdata.body, createWriteStream(certdataFile)); 190 191// Run tools/mk-ca-bundle.pl to generate src/node_root_certs.h. 192if (values.verbose) { 193 console.log('Running tools/mk-ca-bundle.pl'); 194} 195const opts = { encoding: 'utf8' }; 196const mkCABundleTool = join(checkoutDir, 'tools', 'mk-ca-bundle.pl'); 197const mkCABundleOut = execFileSync(mkCABundleTool, 198 values.verbose ? [ '-v' ] : [], 199 opts); 200if (values.verbose) { 201 console.log(mkCABundleOut); 202} 203 204// Determine certificates added and/or removed. 205const certHeaderFile = relative(process.cwd(), join(checkoutDir, 'src', 'node_root_certs.h')); 206const diff = execFileSync('git', [ 'diff-files', '-u', '--', certHeaderFile ], opts); 207if (values.verbose) { 208 console.log(diff); 209} 210const certsAddedRE = /^\+\/\* (.*) \*\//gm; 211const certsRemovedRE = /^-\/\* (.*) \*\//gm; 212const added = [ ...diff.matchAll(certsAddedRE) ].map((m) => m[1]); 213const removed = [ ...diff.matchAll(certsRemovedRE) ].map((m) => m[1]); 214 215const commitMsg = [ 216 `crypto: update root certificates to NSS ${release[kNSSVersion]}`, 217 '', 218 `This is the certdata.txt[0] from NSS ${release[kNSSVersion]}, released on ${formatDate(release[kNSSDate])}.`, 219 '', 220 `This is the version of NSS that ${release[kFirefoxDate] < now ? 'shipped' : 'will ship'} in Firefox ${release[kFirefoxVersion]} on`, 221 `${formatDate(release[kFirefoxDate])}.`, 222 '', 223]; 224if (added.length > 0) { 225 commitMsg.push('Certificates added:'); 226 commitMsg.push(...added.map((cert) => `- ${cert}`)); 227 commitMsg.push(''); 228} 229if (removed.length > 0) { 230 commitMsg.push('Certificates removed:'); 231 commitMsg.push(...removed.map((cert) => `- ${cert}`)); 232 commitMsg.push(''); 233} 234commitMsg.push(`[0] ${certdataURL}`); 235const delimiter = randomUUID(); 236const properties = [ 237 `NEW_VERSION=${release[kNSSVersion]}`, 238 `COMMIT_MSG<<${delimiter}`, 239 ...commitMsg, 240 delimiter, 241 '', 242].join('\n'); 243if (values.verbose) { 244 console.log(properties); 245} 246const propertyFile = values.file; 247if (propertyFile !== undefined) { 248 console.log(`Writing to ${propertyFile}`); 249 await pipeline(Readable.from(properties), createWriteStream(propertyFile)); 250} 251