Skip to content

Commit 26b2584

Browse files
richardlauRafaelGSS
authored andcommitted
tools: add root certificate update script
Automates the steps from `doc/contributing/maintaining-root-certs.md`. Extend "Tools and deps update" workflow to use the new script to update the root certificates. PR-URL: #47425 Reviewed-By: Michael Dawson <midawson@redhat.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> Reviewed-By: Tobias Nießen <tniessen@tnie.de> Reviewed-By: Ben Noordhuis <info@bnoordhuis.nl> Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
1 parent 747ff43 commit 26b2584

File tree

3 files changed

+259
-1
lines changed

3 files changed

+259
-1
lines changed

.github/workflows/tools.yml

+10-1
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,22 @@ jobs:
167167
cat temp-output
168168
tail -n1 temp-output | grep "NEW_VERSION=" >> "$GITHUB_ENV" || true
169169
rm temp-output
170+
- id: root-certificates
171+
subsystem: crypto
172+
label: crypto, notable-change
173+
run: |
174+
node ./tools/dep_updaters/update-root-certs.mjs -v -f "$GITHUB_ENV"
170175
steps:
171176
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
172177
with:
173178
persist-credentials: false
174179
- run: ${{ matrix.run }}
175180
env:
176181
GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }}
182+
- name: Generate commit message if not set
183+
if: ${{ env.COMMIT_MSG == '' }}
184+
run: |
185+
echo "COMMIT_MSG=${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}" >> "$GITHUB_ENV"
177186
- uses: gr2m/create-or-update-pull-request-action@77596e3166f328b24613f7082ab30bf2d93079d5
178187
# Creates a PR or update the Action's existing PR, or
179188
# no-op if the base branch is already up-to-date.
@@ -183,6 +192,6 @@ jobs:
183192
author: Node.js GitHub Bot <github-bot@iojs.org>
184193
body: This is an automated update of ${{ matrix.id }} to ${{ env.NEW_VERSION }}.
185194
branch: actions/tools-update-${{ matrix.id }} # Custom branch *just* for this Action.
186-
commit-message: '${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}'
195+
commit-message: ${{ env.COMMIT_MSG }}
187196
labels: ${{ matrix.label }}
188197
title: '${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}'

doc/contributing/maintaining-root-certs.md

+15
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ check the [NSS release schedule][].
1515

1616
## Process
1717

18+
The `tools/dep_updaters/update-root-certs.mjs` script automates the update of
19+
the root certificates, including:
20+
21+
* Downloading `certdata.txt` from Mozilla's source control repository.
22+
* Running `tools/mk-ca-bundle.pl` to convert the certificates and generate
23+
`src/node_root_certs.h`.
24+
* Using `git diff-files` to determine which certificate have been added and/or
25+
removed.
26+
27+
Manual instructions are included in the following collapsed section.
28+
29+
<details>
30+
1831
Commands assume that the current working directory is the root of a checkout of
1932
the nodejs/node repository.
2033

@@ -121,5 +134,7 @@ the nodejs/node repository.
121134
- OpenTrust Root CA G3
122135
```
123136

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

0 commit comments

Comments
 (0)