• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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