Skip to content

Commit 25f19d8

Browse files
committed
feat: verify pnpm 12 downloads against npm's registry signature
The action checked the release archive against the `digest` GitHub publishes for it. GitHub serves both the asset and the digest, so whoever can replace one can replace the other: that catches a corrupted download, not a tampered one. From v12 the npm registry carries the same executable, byte for byte, and npm signs `<name>@<version>:<integrity>` with a key that is pinned here. That signature cannot be produced without npm's private key, and behind it sits the maintainer's approval of the staged publish — so v12 and newer are now fetched from the registry and refused unless both the signature and the checksum check out. v11 keeps using the release asset and its digest. Its `dist/` bundles dependencies that the registry copy declares instead, and this action has no step that would install them. Two things fall out of not touching the GitHub API for v12+: - `token` stops mattering there. It exists to lift the anonymous 60 requests/hour limit on the release lookup. - Versions published to npm without a GitHub release install fine now, instead of failing the lookup this action warns about. The pinned keys are the ones pnpm itself pins for `pnpm audit signatures`.
1 parent 4700d73 commit 25f19d8

5 files changed

Lines changed: 386 additions & 190 deletions

File tree

dist/index.js

Lines changed: 177 additions & 173 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/install-pnpm/download.ts

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { HttpClient } from '@actions/http-client'
22
import { spawn } from 'child_process'
33
import { createHash } from 'crypto'
44
import { createReadStream, createWriteStream, existsSync } from 'fs'
5-
import { chmod, copyFile, link, mkdir, rm } from 'fs/promises'
5+
import { chmod, copyFile, link, mkdir, rename, rm } from 'fs/promises'
66
import path from 'path'
77
import { pipeline } from 'stream/promises'
88
import semver from 'semver'
99

10+
import { type PackageSignature, verifyRegistrySignature } from './verify-signature'
11+
1012
// The action downloads pnpm's self-contained release archive and uses
1113
// `pnpm runtime` to install a JavaScript runtime. Both are available from v11
1214
// onward, so that is the oldest major this action can install.
@@ -19,12 +21,46 @@ const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json'
1921

2022
const GITHUB_API = 'https://api.github.com'
2123

22-
export interface ResolvedPnpm {
23-
readonly version: string
24-
readonly downloadUrl: string
25-
// Hex-encoded SHA-256 of the release archive, from the GitHub asset `digest`.
26-
readonly sha256: string
27-
readonly archive: 'tar.gz' | 'zip'
24+
/**
25+
* Where the executable is fetched from, and what proves it is the right one.
26+
*
27+
* From v12 the packages on the npm registry hold the same executable as the
28+
* release assets, byte for byte, and npm signs a checksum for them with a key
29+
* this action pins — so a tampered download cannot pass. GitHub publishes a
30+
* digest but serves it from the same place as the asset, which catches
31+
* corruption rather than tampering, so it is used only where there is nothing
32+
* better: v11, whose `dist/` bundles dependencies the registry copy declares
33+
* instead.
34+
*/
35+
export type ResolvedPnpm =
36+
| {
37+
readonly source: 'github'
38+
readonly version: string
39+
readonly downloadUrl: string
40+
// Hex-encoded SHA-256 of the release archive, from the GitHub asset `digest`.
41+
readonly sha256: string
42+
readonly archive: 'tar.gz' | 'zip'
43+
}
44+
| {
45+
readonly source: 'registry'
46+
readonly version: string
47+
readonly packages: readonly RegistryPackage[]
48+
}
49+
50+
interface RegistryPackage {
51+
readonly name: string
52+
readonly tarball: string
53+
readonly integrity: string
54+
/** Entry to lift out of the tarball's `package/` root into the destination. */
55+
readonly keep: string
56+
}
57+
58+
interface VersionMetadata {
59+
readonly dist: {
60+
readonly tarball: string
61+
readonly integrity?: string
62+
readonly signatures?: readonly PackageSignature[]
63+
}
2864
}
2965

3066
interface AbbreviatedPackument {
@@ -53,6 +89,10 @@ To install older pnpm, use the pnpm/action-setup action instead.`)
5389
}
5490

5591
const platform = getPlatform()
92+
if (semver.major(version) >= 12) {
93+
return resolveFromRegistry(version, platform)
94+
}
95+
5696
const asset = assetName(platform)
5797
const release = await fetchRelease(version, token)
5898
const found = release.assets.find((a) => a.name === asset)
@@ -67,13 +107,42 @@ To install older pnpm, use the pnpm/action-setup action instead.`)
67107
throw new Error(`Release asset ${asset} for pnpm ${version} has no sha256 digest (got ${found.digest ?? '<missing>'}).`)
68108
}
69109
return {
110+
source: 'github',
70111
version,
71112
downloadUrl: found.browser_download_url,
72113
sha256: found.digest.slice('sha256:'.length),
73114
archive: platform.os === 'win32' ? 'zip' : 'tar.gz',
74115
}
75116
}
76117

118+
/**
119+
* The executable and the `dist/` tree it loads are published as two packages:
120+
* the platform package holds the binary, `pnpm` holds `dist/`. Both are
121+
* verified the same way.
122+
*/
123+
async function resolveFromRegistry(version: string, platform: Platform): Promise<ResolvedPnpm> {
124+
const exe = platform.os === 'win32' ? 'pnpm.exe' : 'pnpm'
125+
const wanted = [
126+
{ name: platformPackageName(platform), keep: exe },
127+
{ name: 'pnpm', keep: 'dist' },
128+
]
129+
const packages = await Promise.all(wanted.map(async ({ name, keep }) => {
130+
const meta = await fetchJson<VersionMetadata>(`${REGISTRY}/${name}/${version}`)
131+
const integrity = meta.dist.integrity
132+
if (!integrity) {
133+
throw new Error(`The npm registry published no checksum for ${name}@${version}.`)
134+
}
135+
verifyRegistrySignature({ name, version, integrity, signatures: meta.dist.signatures })
136+
return { name, tarball: meta.dist.tarball, integrity, keep }
137+
}))
138+
return { source: 'registry', version, packages }
139+
}
140+
141+
// Platform packages are named `@pnpm/exe.<os>-<arch>[-musl]`.
142+
function platformPackageName({ os, arch, musl }: Platform): string {
143+
return `@pnpm/exe.${os}-${arch}${musl ? '-musl' : ''}`
144+
}
145+
77146
/**
78147
* Downloads and extracts the pnpm release archive into `destDir`, returning the
79148
* path to the `pnpm` executable. The archive holds the executable at its root
@@ -86,16 +155,19 @@ export async function downloadPnpm(resolved: ResolvedPnpm, destDir: string): Pro
86155
const tmpDir = path.join(destDir, '.download')
87156
await mkdir(tmpDir, { recursive: true })
88157

89-
const archivePath = path.join(tmpDir, resolved.archive === 'zip' ? 'pnpm.zip' : 'pnpm.tgz')
90-
const response = await http.get(resolved.downloadUrl)
91-
if (response.message.statusCode !== 200) {
92-
response.message.resume()
93-
throw new Error(`Failed to download ${resolved.downloadUrl}: HTTP ${response.message.statusCode}`)
158+
if (resolved.source === 'registry') {
159+
await downloadFromRegistry(resolved.packages, destDir, tmpDir)
160+
} else {
161+
const archivePath = path.join(tmpDir, resolved.archive === 'zip' ? 'pnpm.zip' : 'pnpm.tgz')
162+
const response = await http.get(resolved.downloadUrl)
163+
if (response.message.statusCode !== 200) {
164+
response.message.resume()
165+
throw new Error(`Failed to download ${resolved.downloadUrl}: HTTP ${response.message.statusCode}`)
166+
}
167+
await pipeline(response.message, createWriteStream(archivePath))
168+
await verifySha256(archivePath, resolved.sha256, resolved.downloadUrl)
169+
await extractArchive(archivePath, destDir, resolved.archive)
94170
}
95-
await pipeline(response.message, createWriteStream(archivePath))
96-
await verifySha256(archivePath, resolved.sha256, resolved.downloadUrl)
97-
98-
await extractArchive(archivePath, destDir, resolved.archive)
99171
await rm(tmpDir, { recursive: true, force: true })
100172

101173
const exe = process.platform === 'win32' ? 'pnpm.exe' : 'pnpm'
@@ -216,6 +288,47 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
216288
return response.result
217289
}
218290

291+
/**
292+
* Fetches each package, checks it against the checksum npm signed for it, and
293+
* lifts the wanted entry out of the tarball's `package/` root. Unpacking
294+
* happens away from `destDir`, whose layout the executable depends on.
295+
*/
296+
async function downloadFromRegistry(
297+
packages: readonly RegistryPackage[],
298+
destDir: string,
299+
tmpDir: string,
300+
): Promise<void> {
301+
for (const pkg of packages) {
302+
const safeName = pkg.name.replace(/[@/]/g, '_')
303+
const archivePath = path.join(tmpDir, `${safeName}.tgz`)
304+
const response = await http.get(pkg.tarball)
305+
if (response.message.statusCode !== 200) {
306+
response.message.resume()
307+
throw new Error(`Failed to download ${pkg.tarball}: HTTP ${response.message.statusCode}`)
308+
}
309+
await pipeline(response.message, createWriteStream(archivePath))
310+
await verifyIntegrity(archivePath, pkg)
311+
312+
const unpackDir = path.join(tmpDir, safeName)
313+
await mkdir(unpackDir, { recursive: true })
314+
await extractArchive(archivePath, unpackDir, 'tar.gz')
315+
await rm(path.join(destDir, pkg.keep), { recursive: true, force: true })
316+
await rename(path.join(unpackDir, 'package', pkg.keep), path.join(destDir, pkg.keep))
317+
}
318+
}
319+
320+
async function verifyIntegrity(file: string, pkg: RegistryPackage): Promise<void> {
321+
const [algorithm, expected] = pkg.integrity.split('-')
322+
const hash = createHash(algorithm)
323+
await pipeline(createReadStream(file), hash)
324+
const actual = hash.digest('base64')
325+
if (actual !== expected) {
326+
throw new Error(`${pkg.name}@${pkg.integrity} does not match the checksum the npm registry published for it. Refusing to install.
327+
Expected ${algorithm}: ${expected}
328+
Actual ${algorithm}: ${actual}`)
329+
}
330+
}
331+
219332
async function verifySha256(file: string, expectedHex: string, url: string): Promise<void> {
220333
const hash = createHash('sha256')
221334
await pipeline(createReadStream(file), hash)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* eslint-disable */
2+
// GENERATED — npm's public registry signing keys, mirrored from
3+
// https://registry.npmjs.org/-/npm/v1/keys
4+
//
5+
// Mirrored from the copy pnpm itself pins (pnpm/pnpm, deps/security/signatures).
6+
// Refresh from https://registry.npmjs.org/-/npm/v1/keys when npm rotates a key;
7+
// an unrecognised keyid fails the install rather than weakening the check.
8+
export const NPM_SIGNING_KEYS = [
9+
{
10+
"expires": null,
11+
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
12+
"keytype": "ecdsa-sha2-nistp256",
13+
"scheme": "ecdsa-sha2-nistp256",
14+
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="
15+
},
16+
{
17+
"expires": "2025-01-29T00:00:00.000Z",
18+
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
19+
"keytype": "ecdsa-sha2-nistp256",
20+
"scheme": "ecdsa-sha2-nistp256",
21+
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
22+
}
23+
] as const satisfies ReadonlyArray<{
24+
expires: string | null
25+
keyid: string
26+
keytype: string
27+
scheme: string
28+
key: string
29+
}>

src/install-pnpm/run.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export async function runSelfInstaller(inputs: Inputs): Promise<SelfInstallerRes
1818

1919
const spec = readTargetVersion({ version, packageJsonFile })
2020
const resolved = await resolvePnpm(spec, token)
21-
info(`Downloading pnpm ${resolved.version} from ${resolved.downloadUrl}`)
21+
info(resolved.source === 'registry'
22+
? `Downloading pnpm ${resolved.version} from the npm registry`
23+
: `Downloading pnpm ${resolved.version} from ${resolved.downloadUrl}`)
2224

2325
await rm(dest, { recursive: true, force: true })
2426
// Create dest/bin upfront: pnpm ≤ 12.0.0-alpha.17 refuses to run
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createVerify } from 'crypto'
2+
3+
import { NPM_SIGNING_KEYS } from './npm-signing-keys'
4+
5+
export interface PackageSignature {
6+
readonly keyid: string
7+
readonly sig: string
8+
}
9+
10+
/**
11+
* Checks the registry's signature over a package's identity and checksum.
12+
*
13+
* The registry serves both the tarball and the checksum, so a checksum taken
14+
* from it proves nothing on its own. The signature is what makes it worth
15+
* something: it is made with a key npm publishes but the download host cannot
16+
* mint, and the trusted copy of that key is pinned in this action.
17+
*
18+
* @throws if the package is unsigned, signed with a key that isn't pinned or
19+
* has expired, or the signature does not verify.
20+
*/
21+
export function verifyRegistrySignature(opts: {
22+
readonly name: string
23+
readonly version: string
24+
readonly integrity: string
25+
readonly signatures?: readonly PackageSignature[]
26+
}): void {
27+
const pkg = `${opts.name}@${opts.version}`
28+
const signature = opts.signatures?.[0]
29+
if (!signature) {
30+
throw new Error(`${pkg} carries no npm registry signature, so it cannot be verified.`)
31+
}
32+
33+
const key = NPM_SIGNING_KEYS.find(({ keyid }) => keyid === signature.keyid)
34+
if (!key) {
35+
throw new Error(`${pkg} is signed with an unexpected npm key (${signature.keyid}). `
36+
+ 'If npm has rotated its signing key, this action needs updating.')
37+
}
38+
if (key.expires && new Date(key.expires) < new Date()) {
39+
throw new Error(`${pkg} is signed with an npm key that expired on ${key.expires}.`)
40+
}
41+
42+
// Registry signatures cover the package identity and its content hash.
43+
const message = `${pkg}:${opts.integrity}`
44+
const publicKey = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`
45+
if (!createVerify('SHA256').update(message).verify(publicKey, signature.sig, 'base64')) {
46+
throw new Error(`The npm registry signature for ${pkg} is not valid. Refusing to install.`)
47+
}
48+
}

0 commit comments

Comments
 (0)