Skip to content

Commit

Permalink
tools: add root certificate update script
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
richardlau authored Apr 7, 2023
1 parent 4b448c8 commit a75871a
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 1 deletion.
11 changes: 10 additions & 1 deletion .github/workflows/tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,22 @@ jobs:
cat temp-output
tail -n1 temp-output | grep "NEW_VERSION=" >> "$GITHUB_ENV" || true
rm temp-output
- id: root-certificates
subsystem: crypto
label: crypto, notable-change
run: |
node ./tools/dep_updaters/update-root-certs.mjs -v -f "$GITHUB_ENV"
steps:
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
persist-credentials: false
- run: ${{ matrix.run }}
env:
GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }}
- name: Generate commit message if not set
if: ${{ env.COMMIT_MSG == '' }}
run: |
echo "COMMIT_MSG=${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}" >> "$GITHUB_ENV"
- uses: gr2m/create-or-update-pull-request-action@77596e3166f328b24613f7082ab30bf2d93079d5
# Creates a PR or update the Action's existing PR, or
# no-op if the base branch is already up-to-date.
Expand All @@ -183,6 +192,6 @@ jobs:
author: Node.js GitHub Bot <github-bot@iojs.org>
body: This is an automated update of ${{ matrix.id }} to ${{ env.NEW_VERSION }}.
branch: actions/tools-update-${{ matrix.id }} # Custom branch *just* for this Action.
commit-message: '${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}'
commit-message: ${{ env.COMMIT_MSG }}
labels: ${{ matrix.label }}
title: '${{ matrix.subsystem }}: update ${{ matrix.id }} to ${{ env.NEW_VERSION }}'
15 changes: 15 additions & 0 deletions doc/contributing/maintaining-root-certs.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ check the [NSS release schedule][].

## Process

The `tools/dep_updaters/update-root-certs.mjs` script automates the update of
the root certificates, including:

* Downloading `certdata.txt` from Mozilla's source control repository.
* Running `tools/mk-ca-bundle.pl` to convert the certificates and generate
`src/node_root_certs.h`.
* Using `git diff-files` to determine which certificate have been added and/or
removed.

Manual instructions are included in the following collapsed section.

<details>

Commands assume that the current working directory is the root of a checkout of
the nodejs/node repository.

Expand Down Expand Up @@ -121,5 +134,7 @@ the nodejs/node repository.
- OpenTrust Root CA G3
```

</details>

[NSS release schedule]: https://wiki.mozilla.org/NSS:Release_Versions
[tag list]: https://hg.mozilla.org/projects/nss/tags
234 changes: 234 additions & 0 deletions tools/dep_updaters/update-root-certs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// Script to update certdata.txt from NSS.
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { basename, join, relative } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';

// Constants for NSS release metadata.
const kNSSVersion = 'version';
const kNSSDate = 'date';
const kFirefoxVersion = 'firefoxVersion';
const kFirefoxDate = 'firefoxDate';

const __filename = fileURLToPath(import.meta.url);
const now = new Date();

const formatDate = (d) => {
const iso = d.toISOString();
return iso.substring(0, iso.indexOf('T'));
};

const normalizeTD = (text) => {
// Remove whitespace and any HTML tags.
return text?.trim().replace(/<.*?>/g, '');
};
const getReleases = (text) => {
const releases = [];
const tableRE = /<table [^>]+>([\S\s]*?)<\/table>/g;
const tableRowRE = /<tr ?[^>]*>([\S\s]*?)<\/tr>/g;
const tableHeaderRE = /<th ?[^>]*>([\S\s]*?)<\/th>/g;
const tableDataRE = /<td ?[^>]*>([\S\s]*?)<\/td>/g;
for (const table of text.matchAll(tableRE)) {
const columns = {};
const matches = table[1].matchAll(tableRowRE);
// First row has the table header.
let row = matches.next();
if (row.done) {
continue;
}
const headers = Array.from(row.value[1].matchAll(tableHeaderRE), (m) => m[1]);
if (headers.length > 0) {
for (let i = 0; i < headers.length; i++) {
if (/NSS version/i.test(headers[i])) {
columns[kNSSVersion] = i;
} else if (/Release.*from branch/i.test(headers[i])) {
columns[kNSSDate] = i;
} else if (/Firefox version/i.test(headers[i])) {
columns[kFirefoxVersion] = i;
} else if (/Firefox release date/i.test(headers[i])) {
columns[kFirefoxDate] = i;
}
}
}
// Filter out "NSS Certificate bugs" table.
if (columns[kNSSDate] === undefined) {
continue;
}
// Scrape releases.
row = matches.next();
while (!row.done) {
const cells = Array.from(row.value[1].matchAll(tableDataRE), (m) => m[1]);
const release = {};
release[kNSSVersion] = normalizeTD(cells[columns[kNSSVersion]]);
release[kNSSDate] = new Date(normalizeTD(cells[columns[kNSSDate]]));
release[kFirefoxVersion] = normalizeTD(cells[columns[kFirefoxVersion]]);
release[kFirefoxDate] = new Date(normalizeTD(cells[columns[kFirefoxDate]]));
releases.push(release);
row = matches.next();
}
}
return releases;
};

const getLatestVersion = (releases) => {
const arrayNumberSort = (x, y, i) => {
if (x[i] === undefined && y[i] === undefined) {
return 0;
} else if (x[i] === y[i]) {
return arrayNumberSort(x, y, i + 1);
}
return (x[i] ?? 0) - (y[i] ?? 0);
};
const extractVersion = (t) => {
return t[kNSSVersion].split('.').map((n) => parseInt(n));
};
const releaseSorter = (x, y) => {
return arrayNumberSort(extractVersion(x), extractVersion(y), 0);
};
return releases.sort(releaseSorter).filter(pastRelease).at(-1)[kNSSVersion];
};

const pastRelease = (r) => {
return r[kNSSDate] < now;
};

const options = {
help: {
type: 'boolean',
},
file: {
short: 'f',
type: 'string',
},
verbose: {
short: 'v',
type: 'boolean',
},
};
const {
positionals,
values,
} = parseArgs({
allowPositionals: true,
options,
});

if (values.help) {
console.log(`Usage: ${basename(__filename)} [OPTION]... [VERSION]...`);
console.log();
console.log('Updates certdata.txt to NSS VERSION (most recent release by default).');
console.log('');
console.log(' -f, --file=FILE writes a commit message reflecting the change to the');
console.log(' specified FILE');
console.log(' -v, --verbose writes progress to stdout');
console.log(' --help display this help and exit');
process.exit(0);
}

if (values.verbose) {
console.log('Fetching NSS release schedule');
}
const scheduleURL = 'https://wiki.mozilla.org/NSS:Release_Versions';
const schedule = await fetch(scheduleURL);
if (!schedule.ok) {
console.error(`Failed to fetch ${scheduleURL}: ${schedule.status}: ${schedule.statusText}`);
process.exit(-1);
}
const scheduleText = await schedule.text();
const nssReleases = getReleases(scheduleText);

// Retrieve metadata for the NSS release being updated to.
const version = positionals[0] ?? getLatestVersion(nssReleases);
const release = nssReleases.find((r) => {
return new RegExp(`^${version.replace('.', '\\.')}\\b`).test(r[kNSSVersion]);
});
if (!pastRelease(release)) {
console.warn(`Warning: NSS ${version} is not due to be released until ${formatDate(release[kNSSDate])}`);
}
if (values.verbose) {
console.log('Found NSS version:');
console.log(release);
}

// Fetch certdata.txt and overwrite the local copy.
const tag = `NSS_${version.replaceAll('.', '_')}_RTM`;
const certdataURL = `https://hg.mozilla.org/projects/nss/raw-file/${tag}/lib/ckfw/builtins/certdata.txt`;
if (values.verbose) {
console.log(`Fetching ${certdataURL}`);
}
const checkoutDir = join(__filename, '..', '..', '..');
const certdata = await fetch(certdataURL);
const certdataFile = join(checkoutDir, 'tools', 'certdata.txt');
if (!certdata.ok) {
console.error(`Failed to fetch ${certdataURL}: ${certdata.status}: ${certdata.statusText}`);
process.exit(-1);
}
if (values.verbose) {
console.log(`Writing ${certdataFile}`);
}
await pipeline(certdata.body, createWriteStream(certdataFile));

// Run tools/mk-ca-bundle.pl to generate src/node_root_certs.h.
if (values.verbose) {
console.log('Running tools/mk-ca-bundle.pl');
}
const opts = { encoding: 'utf8' };
const mkCABundleTool = join(checkoutDir, 'tools', 'mk-ca-bundle.pl');
const mkCABundleOut = execFileSync(mkCABundleTool,
values.verbose ? [ '-v' ] : [],
opts);
if (values.verbose) {
console.log(mkCABundleOut);
}

// Determine certificates added and/or removed.
const certHeaderFile = relative(process.cwd(), join(checkoutDir, 'src', 'node_root_certs.h'));
const diff = execFileSync('git', [ 'diff-files', '-u', '--', certHeaderFile ], opts);
if (values.verbose) {
console.log(diff);
}
const certsAddedRE = /^\+\/\* (.*) \*\//gm;
const certsRemovedRE = /^-\/\* (.*) \*\//gm;
const added = [ ...diff.matchAll(certsAddedRE) ].map((m) => m[1]);
const removed = [ ...diff.matchAll(certsRemovedRE) ].map((m) => m[1]);

const commitMsg = [
`crypto: update root certificates to NSS ${release[kNSSVersion]}`,
'',
`This is the certdata.txt[0] from NSS ${release[kNSSVersion]}, released on ${formatDate(release[kNSSDate])}.`,
'',
`This is the version of NSS that ${release[kFirefoxDate] < now ? 'shipped' : 'will ship'} in Firefox ${release[kFirefoxVersion]} on`,
`${formatDate(release[kFirefoxDate])}.`,
'',
];
if (added.length > 0) {
commitMsg.push('Certificates added:');
commitMsg.push(...added.map((cert) => `- ${cert}`));
commitMsg.push('');
}
if (removed.length > 0) {
commitMsg.push('Certificates removed:');
commitMsg.push(...removed.map((cert) => `- ${cert}`));
commitMsg.push('');
}
commitMsg.push(`[0] ${certdataURL}`);
const delimiter = randomUUID();
const properties = [
`NEW_VERSION=${release[kNSSVersion]}`,
`COMMIT_MSG<<${delimiter}`,
...commitMsg,
delimiter,
'',
].join('\n');
if (values.verbose) {
console.log(properties);
}
const propertyFile = values.file;
if (propertyFile !== undefined) {
console.log(`Writing to ${propertyFile}`);
await pipeline(Readable.from(properties), createWriteStream(propertyFile));
}

0 comments on commit a75871a

Please sign in to comment.