mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 01:45:30 -04:00
fix(security): verify Node.js runtime SHASUMS OpenPGP signature (#12295)
Follow-up to #12292 (which verifies the **package-manager** binary). This closes the same class of gap for the **Node.js runtime**. When a repository requests a Node.js runtime — `devEngines.runtime: node@X` (with `onFail: download`, the default) or `useNodeVersion` — pnpm downloads and then executes a Node binary (it's used to run lifecycle / `run` / `exec` scripts). The download **mirror is repository-configurable** via `node-mirror:<channel>` (`nodeDownloadMirrors`) in project `.npmrc`, and the integrity comes from `SHASUMS256.txt` fetched **from that same mirror**. That's a circular check: a malicious mirror serves a tampered `node` tarball **and** a matching `SHASUMS256.txt`, the sha256 check passes, and pnpm runs the binary. Drive-by on a normal command in a cloned repo. ## Fix pnpm now fetches `SHASUMS256.txt.sig` and verifies its **detached OpenPGP signature** against the **Node.js release team's public keys, embedded in the pnpm CLI**, before trusting the hashes. A mirror that serves a tampered binary cannot also produce a valid signature, so verification fails. Any faithful mirror (one that proxies the real signed SHASUMS) keeps working. - `@pnpm/crypto.shasums-file`: new `fetchVerifiedNodeShasums` / `fetchVerifiedNodeShasumsFile` verify the signature via `openpgp` against the embedded keys. - The keys live in a generated file (`src/nodeReleaseKeys.ts`, 28 keys) mirrored from the canonical `nodejs/release-keys` list. `crypto/shasums-file/scripts/update-node-release-keys.mjs` keeps them current (`pnpm check:node-release-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate so a new release signer can't silently break verification. - `@pnpm/engine.runtime.node-resolver` verifies the **configurable-mirror** SHASUMS. The hardcoded `unofficial-builds.nodejs.org` musl mirror is **not** repo-configurable and is signed by a different key, so it stays trusted over TLS. ## Scope - **Pre-release channels (rc, nightly, …) are not verified** — Node only signs the `release` channel (no `SHASUMS256.txt.sig` exists for them, even on nodejs.org), so they remain unverifiable. Verification is gated on the `release` channel. - **Bun / Deno are unaffected** — their download/SHASUMS URLs are hardcoded to canonical GitHub (`github.com/oven-sh/bun`, `api.github.com/repos/denoland/deno`), not mirror-configurable, so a repo can't redirect them. - **Pacquet parity:** `pacquet/crates/engine-runtime-node-resolver` has the same mirror-configurable SHASUMS logic and needs the equivalent Rust port — tracked as a follow-up (per the repo's parity rule, opening the TS side first).
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
[alias]
|
||||
# Do not append `--` or it will break IDEs
|
||||
codecov = "llvm-cov nextest --workspace --ignore-filename-regex tasks"
|
||||
codecov = "llvm-cov nextest --workspace --exclude pacquet-integrated-benchmark --exclude pacquet-micro-benchmark --exclude pacquet-registry-mock --ignore-filename-regex tasks"
|
||||
|
||||
13
.changeset/verify-node-runtime-shasums.md
Normal file
13
.changeset/verify-node-runtime-shasums.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@pnpm/crypto.shasums-file": minor
|
||||
"@pnpm/engine.runtime.node-resolver": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Security: pnpm now verifies the OpenPGP signature of a downloaded Node.js runtime's `SHASUMS256.txt` before trusting its integrity hashes.
|
||||
|
||||
When a repository requests a Node.js runtime (e.g. via `devEngines.runtime` / `useNodeVersion`), the download mirror is repository-configurable through `node-mirror:<channel>`. The integrity of the downloaded binary was only checked against `SHASUMS256.txt` fetched from that same mirror — a circular check that a malicious mirror could satisfy by serving a tampered binary together with a matching `SHASUMS256.txt`. pnpm then executes the binary (for example to run lifecycle scripts).
|
||||
|
||||
pnpm now fetches `SHASUMS256.txt.sig` and verifies the detached OpenPGP signature against the Node.js release team's public keys, which ship embedded in the pnpm CLI. A mirror that serves a tampered binary cannot also produce a valid signature, so the download fails to verify. The embedded keys are kept current by a release-time check against the canonical `nodejs/release-keys` list.
|
||||
|
||||
The musl variants from the hardcoded `unofficial-builds.nodejs.org` mirror are not repository-configurable and are signed by a different key, so they continue to be trusted over TLS.
|
||||
7
.github/workflows/create-release-pr.yml
vendored
7
.github/workflows/create-release-pr.yml
vendored
@@ -57,6 +57,13 @@ jobs:
|
||||
- name: Check embedded npm signing keys are up to date
|
||||
run: node deps/security/signatures/scripts/update-npm-signing-keys.mjs
|
||||
|
||||
# Fail the release if the embedded Node.js release keys (used to verify the
|
||||
# signature of a downloaded runtime's SHASUMS256.txt) have drifted from the
|
||||
# canonical nodejs/release-keys list, so a new release signer cannot silently
|
||||
# break Node.js runtime verification.
|
||||
- name: Check embedded Node.js release keys are up to date
|
||||
run: node crypto/shasums-file/scripts/update-node-release-keys.mjs
|
||||
|
||||
# Consumes the pending changesets: bumps versions, writes changelogs, updates
|
||||
# the ledger, and syncs manifests. A no-op (no pending changesets) leaves the
|
||||
# tree clean and the steps below skip.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
# typos
|
||||
|
||||
[files]
|
||||
extend-exclude = []
|
||||
extend-exclude = [
|
||||
"pacquet/crates/crypto-shasums-file/src/node_release_keys.rs",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
cafs = "cafs"
|
||||
|
||||
1078
Cargo.lock
generated
1078
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ reqwest = { version = "0.13", default-features = false, features = [
|
||||
node-semver = { version = "2.2.0" }
|
||||
pathdiff = { version = "0.2.3" }
|
||||
pipe-trait = { version = "0.4.0" }
|
||||
pgp = { version = "0.19.0", default-features = false }
|
||||
rayon = { version = "1.12.0" }
|
||||
rmp-serde = { version = "1.3.0" }
|
||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
"dependencies": {
|
||||
"@pnpm/crypto.hash": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fetching.types": "workspace:*"
|
||||
"@pnpm/fetching.types": "workspace:*",
|
||||
"openpgp": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@openpgp/web-stream-tools": "catalog:",
|
||||
"@pnpm/crypto.shasums-file": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
117
crypto/shasums-file/scripts/update-node-release-keys.mjs
Normal file
117
crypto/shasums-file/scripts/update-node-release-keys.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// Mirrors the Node.js release team's OpenPGP public keys (used to verify the
|
||||
// signature of SHASUMS256.txt) from the canonical nodejs/release-keys repo into
|
||||
// src/nodeReleaseKeys.ts and pacquet's matching Rust key module.
|
||||
//
|
||||
// node update-node-release-keys.mjs # check (CI / release gate)
|
||||
// node update-node-release-keys.mjs --update # rewrite the embedded keys
|
||||
//
|
||||
// `--check` fails when the authoritative keys.list contains a fingerprint that
|
||||
// is not embedded, so a newly added release signer cannot silently break Node
|
||||
// runtime verification.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const RAW = 'https://raw.githubusercontent.com/nodejs/release-keys/main'
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const TS_KEYS_FILE = path.join(ROOT, 'crypto', 'shasums-file', 'src', 'nodeReleaseKeys.ts')
|
||||
const RUST_KEYS_FILE = path.join(ROOT, 'pacquet', 'crates', 'crypto-shasums-file', 'src', 'node_release_keys.rs')
|
||||
|
||||
async function main () {
|
||||
const update = process.argv.includes('--update')
|
||||
const fingerprints = (await (await fetchOk(`${RAW}/keys.list`)).text())
|
||||
.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
|
||||
const embedded = [
|
||||
readEmbeddedFingerprints(TS_KEYS_FILE, /fingerprint: '([0-9A-F]+)'/g),
|
||||
readEmbeddedFingerprints(RUST_KEYS_FILE, /fingerprint: "([0-9A-F]+)"/g),
|
||||
]
|
||||
const missing = embedded.flatMap(({ label, fingerprints: embeddedFingerprints }) =>
|
||||
fingerprints.filter((fp) => !embeddedFingerprints.includes(fp)).map((fp) => ({ label, fp }))
|
||||
)
|
||||
// Keys embedded here but no longer in the canonical list (e.g. revoked/rotated)
|
||||
// must NOT stay in the trust set, so they fail the check too.
|
||||
const extra = embedded.flatMap(({ label, fingerprints: embeddedFingerprints }) =>
|
||||
embeddedFingerprints.filter((fp) => !fingerprints.includes(fp)).map((fp) => ({ label, fp }))
|
||||
)
|
||||
|
||||
if (!update) {
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`✓ Embedded Node.js release keys are up to date (${fingerprints.length} key(s)).`)
|
||||
return
|
||||
}
|
||||
console.error('✗ Embedded Node.js release keys are out of sync with nodejs/release-keys.')
|
||||
if (missing.length > 0) console.error(` Missing (add): ${formatDrift(missing)}`)
|
||||
if (extra.length > 0) console.error(` No longer canonical (remove — possibly revoked): ${formatDrift(extra)}`)
|
||||
console.error(`Run: node ${path.relative(process.cwd(), fileURLToPath(import.meta.url))} --update`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const keys = []
|
||||
for (const fp of fingerprints) {
|
||||
const armored = (await (await fetchOk(`${RAW}/keys/${fp}.asc`)).text()).trim()
|
||||
keys.push({ fingerprint: fp, armored })
|
||||
}
|
||||
fs.writeFileSync(TS_KEYS_FILE, renderTypeScript(keys))
|
||||
fs.writeFileSync(RUST_KEYS_FILE, renderRust(keys))
|
||||
console.log(`✓ Wrote ${keys.length} Node.js release key(s).`)
|
||||
}
|
||||
|
||||
async function fetchOk (url) {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
|
||||
return res
|
||||
}
|
||||
|
||||
function readEmbeddedFingerprints (file, pattern) {
|
||||
const label = path.relative(ROOT, file)
|
||||
if (!fs.existsSync(file)) return { label, fingerprints: [] }
|
||||
return {
|
||||
label,
|
||||
fingerprints: [...fs.readFileSync(file, 'utf8').matchAll(pattern)].map((m) => m[1]),
|
||||
}
|
||||
}
|
||||
|
||||
function formatDrift (items) {
|
||||
return items.map(({ label, fp }) => `${label}: ${fp}`).join(', ')
|
||||
}
|
||||
|
||||
function renderTypeScript (keys) {
|
||||
const entries = keys.map(({ fingerprint, armored }) =>
|
||||
` {\n fingerprint: '${fingerprint}',\n armoredKey: ${JSON.stringify(`${armored}\n`)},\n },`).join('\n')
|
||||
return `/* eslint-disable */
|
||||
// cspell:disable
|
||||
// GENERATED — the Node.js release team's OpenPGP public keys, mirrored from
|
||||
// <https://github.com/nodejs/release-keys> (keys.list + keys/<fingerprint>.asc).
|
||||
//
|
||||
// Used to verify the signature of a Node.js release's SHASUMS256.txt before
|
||||
// trusting its hashes. Refresh with:
|
||||
// node crypto/shasums-file/scripts/update-node-release-keys.mjs --update
|
||||
export const NODE_RELEASE_KEYS = [
|
||||
${entries}
|
||||
] as const satisfies ReadonlyArray<{ fingerprint: string, armoredKey: string }>
|
||||
`
|
||||
}
|
||||
|
||||
function renderRust (keys) {
|
||||
const entries = keys.map(({ fingerprint, armored }) =>
|
||||
` NodeReleaseKey {\n fingerprint: "${fingerprint}",\n armored_key: r#"${armored}\n"#,\n },`).join('\n')
|
||||
return `// GENERATED - the Node.js release team's OpenPGP public keys, mirrored from
|
||||
// https://github.com/nodejs/release-keys (keys.list + keys/<fingerprint>.asc).
|
||||
//
|
||||
// Used to verify the signature of a Node.js release's SHASUMS256.txt before
|
||||
// trusting its hashes. Refresh with:
|
||||
// node crypto/shasums-file/scripts/update-node-release-keys.mjs --update
|
||||
pub(crate) struct NodeReleaseKey {
|
||||
pub(crate) fingerprint: &'static str,
|
||||
pub(crate) armored_key: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) const NODE_RELEASE_KEYS: &[NodeReleaseKey] = &[
|
||||
${entries}
|
||||
];
|
||||
`
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
FetchFromRegistry,
|
||||
} from '@pnpm/fetching.types'
|
||||
|
||||
import { fetchVerifiedNodeShasums } from './verifyNodeShasums.js'
|
||||
|
||||
export { fetchVerifiedNodeShasums }
|
||||
|
||||
export interface ShasumsFileItem {
|
||||
integrity: string
|
||||
fileName: string
|
||||
@@ -12,7 +16,23 @@ export async function fetchShasumsFile (
|
||||
fetch: FetchFromRegistry,
|
||||
shasumsUrl: string
|
||||
): Promise<ShasumsFileItem[]> {
|
||||
const shasumsFileContent = await fetchShasumsFileRaw(fetch, shasumsUrl)
|
||||
return parseShasumsFile(await fetchShasumsFileRaw(fetch, shasumsUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link fetchShasumsFile}, but first verifies the SHASUMS file's detached
|
||||
* OpenPGP signature against the Node.js release keys (see
|
||||
* {@link fetchVerifiedNodeShasums}). Use this whenever the SHASUMS file is
|
||||
* fetched from a repository-configurable Node.js mirror.
|
||||
*/
|
||||
export async function fetchVerifiedNodeShasumsFile (
|
||||
fetch: FetchFromRegistry,
|
||||
shasumsUrl: string
|
||||
): Promise<ShasumsFileItem[]> {
|
||||
return parseShasumsFile(await fetchVerifiedNodeShasums(fetch, shasumsUrl))
|
||||
}
|
||||
|
||||
export function parseShasumsFile (shasumsFileContent: string): ShasumsFileItem[] {
|
||||
const lines = shasumsFileContent.split('\n')
|
||||
const items: ShasumsFileItem[] = []
|
||||
for (const line of lines) {
|
||||
|
||||
122
crypto/shasums-file/src/nodeReleaseKeys.ts
Normal file
122
crypto/shasums-file/src/nodeReleaseKeys.ts
Normal file
File diff suppressed because one or more lines are too long
120
crypto/shasums-file/src/verifyNodeShasums.ts
Normal file
120
crypto/shasums-file/src/verifyNodeShasums.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { FetchFromRegistry } from '@pnpm/fetching.types'
|
||||
import * as openpgp from 'openpgp'
|
||||
|
||||
import { NODE_RELEASE_KEYS } from './nodeReleaseKeys.js'
|
||||
|
||||
export interface ArmoredKey { armoredKey: string }
|
||||
|
||||
let bundledKeyPacketsPromise: Promise<openpgp.AnyKeyPacket[]> | undefined
|
||||
|
||||
async function loadSigningKeyPackets (trustedKeys: readonly ArmoredKey[]): Promise<openpgp.AnyKeyPacket[]> {
|
||||
if (trustedKeys === NODE_RELEASE_KEYS) {
|
||||
bundledKeyPacketsPromise ??= readSigningKeyPackets(NODE_RELEASE_KEYS)
|
||||
return bundledKeyPacketsPromise
|
||||
}
|
||||
return readSigningKeyPackets(trustedKeys)
|
||||
}
|
||||
|
||||
async function readSigningKeyPackets (trustedKeys: readonly ArmoredKey[]): Promise<openpgp.AnyKeyPacket[]> {
|
||||
const keys = await Promise.all(trustedKeys.map(({ armoredKey }) => openpgp.readKey({ armoredKey })))
|
||||
// A signature may be made by the primary key or any subkey; collect both.
|
||||
return keys.flatMap((key) => [key.keyPacket, ...key.subkeys.map((subkey) => subkey.keyPacket)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a Node.js release's `SHASUMS256.txt` and verifies its detached
|
||||
* OpenPGP signature (`SHASUMS256.txt.sig`) against the Node.js release team's
|
||||
* embedded public keys before returning its content.
|
||||
*
|
||||
* The download mirror is repository-configurable (`node-mirror:<channel>`), so
|
||||
* the SHASUMS file — and the integrity hashes it carries — cannot be trusted on
|
||||
* their own. Verifying the signature against keys embedded in pnpm anchors the
|
||||
* download to the real Node.js release team: a mirror serving a tampered binary
|
||||
* with a matching SHASUMS cannot also produce a valid signature.
|
||||
*
|
||||
* The signature is verified at the packet level (the cryptographic check),
|
||||
* deliberately bypassing OpenPGP key-validity-window checks: the trusted keys
|
||||
* are pinned (mirrored from `nodejs/release-keys`), and the Node.js release keys
|
||||
* are re-certified over time, which would otherwise make signatures on older
|
||||
* releases fail to validate against the current key material.
|
||||
*
|
||||
* Throws when the signature is missing or does not verify against a trusted key.
|
||||
*/
|
||||
export async function fetchVerifiedNodeShasums (
|
||||
fetch: FetchFromRegistry,
|
||||
shasumsUrl: string,
|
||||
trustedKeys: readonly ArmoredKey[] = NODE_RELEASE_KEYS
|
||||
): Promise<string> {
|
||||
const [shasumsBytes, signatureBytes] = await Promise.all([
|
||||
fetchBytes(fetch, shasumsUrl, 'SHASUMS256.txt'),
|
||||
fetchBytes(fetch, `${shasumsUrl}.sig`, 'SHASUMS256.txt.sig'),
|
||||
])
|
||||
|
||||
if (!(await isSignedByTrustedKey(shasumsBytes, signatureBytes, trustedKeys))) {
|
||||
throw new PnpmError(
|
||||
'NODE_SHASUMS_SIGNATURE_INVALID',
|
||||
`The OpenPGP signature of ${shasumsUrl} does not match any trusted Node.js release key. ` +
|
||||
'The downloaded Node.js runtime cannot be verified as a genuine release.'
|
||||
)
|
||||
}
|
||||
|
||||
return Buffer.from(shasumsBytes).toString('utf8')
|
||||
}
|
||||
|
||||
async function isSignedByTrustedKey (
|
||||
content: Uint8Array,
|
||||
signatureBytes: Uint8Array,
|
||||
trustedKeys: readonly ArmoredKey[]
|
||||
): Promise<boolean> {
|
||||
let signature: openpgp.Signature
|
||||
let keyPackets: openpgp.AnyKeyPacket[]
|
||||
try {
|
||||
;[signature, keyPackets] = await Promise.all([
|
||||
openpgp.readSignature({ binarySignature: signatureBytes }),
|
||||
loadSigningKeyPackets(trustedKeys),
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
throw new PnpmError('NODE_SHASUMS_SIGNATURE_INVALID', `Could not read the Node.js SHASUMS signature: ${String(err)}`)
|
||||
}
|
||||
const message = await openpgp.createMessage({ binary: content })
|
||||
const literalDataPacket = message.packets[0]
|
||||
|
||||
const perSignature = await Promise.all(
|
||||
signature.packets.map((signaturePacket) =>
|
||||
signaturePacketVerifies(signaturePacket, keyPackets, literalDataPacket))
|
||||
)
|
||||
return perSignature.some(Boolean)
|
||||
}
|
||||
|
||||
async function signaturePacketVerifies (
|
||||
signaturePacket: openpgp.SignaturePacket,
|
||||
keyPackets: openpgp.AnyKeyPacket[],
|
||||
literalDataPacket: object
|
||||
): Promise<boolean> {
|
||||
const issuerKeyID = signaturePacket.issuerKeyID
|
||||
if (issuerKeyID == null) return false
|
||||
const keyPacket = keyPackets.find((packet) => packet.getKeyID().equals(issuerKeyID))
|
||||
if (keyPacket == null) return false
|
||||
try {
|
||||
// Resolves on a valid signature, rejects otherwise. This is the raw
|
||||
// cryptographic check against a pinned key — no web-of-trust / key-expiry
|
||||
// evaluation, which is what `openpgp.verify` would (incorrectly here) apply.
|
||||
await signaturePacket.verify(keyPacket, signaturePacket.signatureType!, literalDataPacket, signaturePacket.created ?? undefined, true)
|
||||
return true
|
||||
} catch {
|
||||
// Not valid under this key/packet.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBytes (fetch: FetchFromRegistry, url: string, what: string): Promise<Uint8Array> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new PnpmError(
|
||||
'NODE_SHASUMS_FETCH_FAIL',
|
||||
`Failed to fetch ${what} (${url}) to verify the Node.js download (status: ${res.status})`
|
||||
)
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
67
crypto/shasums-file/test/verifyNodeShasums.ts
Normal file
67
crypto/shasums-file/test/verifyNodeShasums.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from '@jest/globals'
|
||||
import { fetchVerifiedNodeShasums } from '@pnpm/crypto.shasums-file'
|
||||
import * as openpgp from 'openpgp'
|
||||
|
||||
const SHASUMS_URL = 'https://nodejs.example.test/download/release/v22.11.0/SHASUMS256.txt'
|
||||
const SHASUMS = 'deadbeef'.repeat(8) + ' node-v22.11.0-darwin-arm64.tar.gz\n'
|
||||
|
||||
async function makeKey () {
|
||||
const { privateKey, publicKey } = await openpgp.generateKey({
|
||||
userIDs: [{ name: 'Test Node Releaser', email: 'test@nodejs.example' }],
|
||||
format: 'armored',
|
||||
})
|
||||
return { privateKey: await openpgp.readPrivateKey({ armoredKey: privateKey }), armoredKey: publicKey }
|
||||
}
|
||||
|
||||
async function detachedSig (privateKey: openpgp.PrivateKey, content: string): Promise<Uint8Array> {
|
||||
const message = await openpgp.createMessage({ binary: new TextEncoder().encode(content) })
|
||||
return openpgp.sign({ message, signingKeys: privateKey, detached: true, format: 'binary' }) as Promise<Uint8Array>
|
||||
}
|
||||
|
||||
function mockFetch (responses: Record<string, { ok: boolean, body?: Uint8Array }>) {
|
||||
return (async (url: string) => {
|
||||
const res = responses[url]
|
||||
if (!res) return { ok: false, status: 404 }
|
||||
return { ok: res.ok, status: res.ok ? 200 : 404, arrayBuffer: async () => res.body!.buffer }
|
||||
}) as never
|
||||
}
|
||||
|
||||
test('returns the SHASUMS content when the detached signature verifies against a trusted key', async () => {
|
||||
const key = await makeKey()
|
||||
const fetch = mockFetch({
|
||||
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
|
||||
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(key.privateKey, SHASUMS) },
|
||||
})
|
||||
|
||||
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).resolves.toBe(SHASUMS)
|
||||
})
|
||||
|
||||
test('throws when the signature was made by an untrusted key', async () => {
|
||||
const signer = await makeKey()
|
||||
const trusted = await makeKey()
|
||||
const fetch = mockFetch({
|
||||
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
|
||||
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(signer.privateKey, SHASUMS) },
|
||||
})
|
||||
|
||||
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [trusted])).rejects.toThrow(/signature/i)
|
||||
})
|
||||
|
||||
test('throws when the SHASUMS content was tampered with after signing', async () => {
|
||||
const key = await makeKey()
|
||||
const fetch = mockFetch({
|
||||
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS.replace('deadbeef', 'tampered0')) },
|
||||
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(key.privateKey, SHASUMS) },
|
||||
})
|
||||
|
||||
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).rejects.toThrow(/signature/i)
|
||||
})
|
||||
|
||||
test('throws when the signature file is missing', async () => {
|
||||
const key = await makeKey()
|
||||
const fetch = mockFetch({
|
||||
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
|
||||
})
|
||||
|
||||
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).rejects.toThrow(/SHASUMS256.txt.sig/)
|
||||
})
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"ignorePaths": [
|
||||
"**/nodeReleaseKeys.ts",
|
||||
"**/nodeReleaseKeys.d.ts",
|
||||
"**/node_release_keys.rs"
|
||||
],
|
||||
"words": [
|
||||
"adduser",
|
||||
"adipiscing",
|
||||
@@ -217,6 +222,7 @@
|
||||
"ofjergrg",
|
||||
"onclickoutside",
|
||||
"oomol",
|
||||
"openpgp",
|
||||
"ossl",
|
||||
"outfile",
|
||||
"overrider",
|
||||
@@ -345,6 +351,8 @@
|
||||
"subdeps",
|
||||
"subdir",
|
||||
"subdirs",
|
||||
"subkey",
|
||||
"subkeys",
|
||||
"subpkg",
|
||||
"subresource",
|
||||
"supercede",
|
||||
|
||||
@@ -22,6 +22,11 @@ unmaintained = "workspace"
|
||||
# yanked-crates check: "deny" | "warn" | "allow"
|
||||
yanked = "warn"
|
||||
ignore = [
|
||||
# `pgp` pulls in `rsa` to verify OpenPGP signatures made by RSA Node.js
|
||||
# release keys. Pacquet only performs public-key signature verification here;
|
||||
# it never handles RSA private keys, so the private-key timing side channel
|
||||
# described by this advisory is not reachable through this use.
|
||||
{ id = "RUSTSEC-2023-0071", reason = "Only public-key OpenPGP signature verification is performed for pinned Node.js release keys; no RSA private-key operation is exposed." },
|
||||
# hickory-proto 0.25.2 is pulled in transitively through reqwest 0.13.x ->
|
||||
# hickory-resolver 0.25.x. The reqwest 0.13 line has not migrated to
|
||||
# hickory-proto 0.26, so `cargo update` cannot resolve either advisory; the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchShasumsFile } from '@pnpm/crypto.shasums-file'
|
||||
import { fetchShasumsFile, fetchVerifiedNodeShasumsFile } from '@pnpm/crypto.shasums-file'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { FetchFromRegistry } from '@pnpm/fetching.types'
|
||||
import type {
|
||||
@@ -63,7 +63,7 @@ export async function resolveNodeRuntime (
|
||||
if (!version) {
|
||||
throw new PnpmError('NODEJS_VERSION_NOT_FOUND', `Could not find a Node.js version that satisfies ${versionSpec}`)
|
||||
}
|
||||
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version)
|
||||
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version, releaseChannel)
|
||||
const range = version === versionSpec ? version : `^${version}`
|
||||
return {
|
||||
id: `node@runtime:${version}` as PkgResolutionId,
|
||||
@@ -96,14 +96,21 @@ export async function resolveLatestNodeRuntime (
|
||||
return { latestManifest: { name: 'node', version } }
|
||||
}
|
||||
|
||||
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string): Promise<PlatformAssetResolution[]> {
|
||||
const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false })
|
||||
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string, releaseChannel: string): Promise<PlatformAssetResolution[]> {
|
||||
// The mirror is repository-configurable, so the SHASUMS file's hashes are only
|
||||
// trustworthy once its OpenPGP signature is verified against the Node.js
|
||||
// release keys embedded in pnpm. Only the `release` channel publishes a signed
|
||||
// SHASUMS256.txt; pre-release channels (rc, nightly, …) are unsigned by Node,
|
||||
// so they cannot be verified this way.
|
||||
const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false, verifySignature: releaseChannel === 'release' })
|
||||
|
||||
// When using the default mirror, also fetch musl variants from unofficial-builds.nodejs.org,
|
||||
// since musl builds are not available on the official mirror.
|
||||
// since musl builds are not available on the official mirror. That URL is hardcoded (not
|
||||
// repository-configurable) and signed by a different (unofficial-builds) key, so it is trusted
|
||||
// over TLS rather than verified against the official release keys.
|
||||
if (nodeMirrorBaseUrl === DEFAULT_NODE_MIRROR_BASE_URL) {
|
||||
try {
|
||||
const muslAssets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl: UNOFFICIAL_NODE_MIRROR_BASE_URL, version, muslOnly: true })
|
||||
const muslAssets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl: UNOFFICIAL_NODE_MIRROR_BASE_URL, version, muslOnly: true, verifySignature: false })
|
||||
assets.push(...muslAssets)
|
||||
} catch {
|
||||
// Musl variants may not be available for all Node.js versions (e.g. very old ones)
|
||||
@@ -119,11 +126,14 @@ async function readNodeAssetsFromMirror (
|
||||
nodeMirrorBaseUrl: string
|
||||
version: string
|
||||
muslOnly: boolean
|
||||
verifySignature: boolean
|
||||
}
|
||||
): Promise<PlatformAssetResolution[]> {
|
||||
const { nodeMirrorBaseUrl, version, muslOnly } = opts
|
||||
const { nodeMirrorBaseUrl, version, muslOnly, verifySignature } = opts
|
||||
const integritiesFileUrl = `${nodeMirrorBaseUrl}v${version}/SHASUMS256.txt`
|
||||
const shasumsFileItems = await fetchShasumsFile(fetch, integritiesFileUrl)
|
||||
const shasumsFileItems = verifySignature
|
||||
? await fetchVerifiedNodeShasumsFile(fetch, integritiesFileUrl)
|
||||
: await fetchShasumsFile(fetch, integritiesFileUrl)
|
||||
const escaped = version.replace(/\\/g, '\\\\').replace(/\./g, '\\.')
|
||||
// The second capture group uses [^.-]+ to stop at a dash, so that the optional
|
||||
// third group can capture the '-musl' suffix separately (e.g. 'x64' + '-musl').
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"make-release-description": "pn --filter=@pnpm/get-release-text run write-release-text",
|
||||
"check:npm-signing-keys": "node deps/security/signatures/scripts/update-npm-signing-keys.mjs",
|
||||
"update:npm-signing-keys": "node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update",
|
||||
"check:node-release-keys": "node crypto/shasums-file/scripts/update-node-release-keys.mjs",
|
||||
"update:node-release-keys": "node crypto/shasums-file/scripts/update-node-release-keys.mjs --update",
|
||||
"release": "pn --filter=@pnpm/exe run build-artifacts && pn --filter=@pnpm/exe publish --tag=next-11 --access=public --provenance && pn publish --filter=!pnpm --filter=!@pnpm/exe --access=public --provenance && pn publish --filter=pnpm --tag=next-11 --access=public --provenance",
|
||||
"dev-setup": "pn -C=./pnpm/dev link -g"
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use command_extra::CommandExtra;
|
||||
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
|
||||
use std::{fs, path::Path, process::Command};
|
||||
use std::{fs, path::Path, process::Command, thread::sleep, time::Duration};
|
||||
|
||||
fn pacquet_at(workspace: &Path) -> Command {
|
||||
Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace)
|
||||
@@ -127,6 +127,10 @@ fn remote_tarball_integrity_survives_unrelated_install() {
|
||||
// Install an unrelated package. This rewrites the lockfile while the
|
||||
// tarball dependency is re-resolved — the exact
|
||||
// <https://github.com/pnpm/pnpm/issues/12001> trigger.
|
||||
// Ensure the manifest mtime is observably newer than the first
|
||||
// install's workspace-state validation timestamp; otherwise the
|
||||
// optimistic repeat-install shortcut can legitimately skip resolution.
|
||||
sleep(Duration::from_millis(20));
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
serde_json::json!({
|
||||
|
||||
@@ -16,9 +16,11 @@ pacquet-network = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
pgp = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
|
||||
@@ -8,21 +8,32 @@
|
||||
//! `sha256-<base64>` integrity string the lockfile records on the
|
||||
//! emitted `BinaryResolution`.
|
||||
//!
|
||||
//! Two surfaces:
|
||||
//! Three surfaces:
|
||||
//!
|
||||
//! - [`fetch_shasums_file`] — download and parse every row at once.
|
||||
//! The node-resolver and bun-resolver fan the parsed rows out across
|
||||
//! every artifact a release ships.
|
||||
//! - [`fetch_verified_node_shasums_file`] — download a Node.js release
|
||||
//! SHASUMS file, verify its detached OpenPGP signature against the
|
||||
//! embedded Node.js release keys, then parse the trusted body.
|
||||
//! - [`pick_file_checksum_from_shasums_file`] — re-parse a previously
|
||||
//! downloaded body to extract the integrity of a single file. The
|
||||
//! verifier path uses it when only one variant's hash is needed.
|
||||
|
||||
use std::sync::Arc;
|
||||
mod node_release_keys;
|
||||
|
||||
use std::{io::Cursor, string::FromUtf8Error, sync::Arc};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_network::ThrottledClient;
|
||||
use pgp::{
|
||||
composed::{Deserializable, DetachedSignature, SignedPublicKey},
|
||||
types::KeyDetails,
|
||||
};
|
||||
|
||||
use node_release_keys::{NODE_RELEASE_KEYS, NodeReleaseKey};
|
||||
|
||||
/// One row parsed out of a `SHASUMS256.txt` body.
|
||||
///
|
||||
@@ -55,6 +66,74 @@ pub enum FetchShasumsFileError {
|
||||
},
|
||||
}
|
||||
|
||||
/// Errors raised by [`fetch_verified_node_shasums`] and
|
||||
/// [`fetch_verified_node_shasums_file`].
|
||||
///
|
||||
/// Mirrors pnpm's `NODE_SHASUMS_FETCH_FAIL` and
|
||||
/// `NODE_SHASUMS_SIGNATURE_INVALID` codes. These are specific to
|
||||
/// Node.js runtime verification, where a repository-configurable
|
||||
/// mirror cannot be trusted to supply both the binary and the hash
|
||||
/// list unchecked.
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
pub enum FetchVerifiedNodeShasumsError {
|
||||
#[display("Failed to fetch {what} ({url}) to verify the Node.js download (status: {status})")]
|
||||
#[diagnostic(code(NODE_SHASUMS_FETCH_FAIL))]
|
||||
StatusNotOk {
|
||||
#[error(not(source))]
|
||||
what: &'static str,
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
status: u16,
|
||||
},
|
||||
|
||||
#[display("Failed to fetch {what} ({url}) to verify the Node.js download")]
|
||||
#[diagnostic(code(NODE_SHASUMS_FETCH_FAIL))]
|
||||
Network {
|
||||
#[error(not(source))]
|
||||
what: &'static str,
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
#[error(source)]
|
||||
error: Arc<reqwest::Error>,
|
||||
},
|
||||
|
||||
#[display("Could not read the Node.js SHASUMS signature: {error}")]
|
||||
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
|
||||
SignatureUnreadable {
|
||||
#[error(source)]
|
||||
error: Arc<pgp::errors::Error>,
|
||||
},
|
||||
|
||||
#[display("The verified Node.js SHASUMS file at {url} is not valid UTF-8")]
|
||||
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
|
||||
InvalidUtf8 {
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
#[error(source)]
|
||||
error: Arc<FromUtf8Error>,
|
||||
},
|
||||
|
||||
#[display(
|
||||
"Embedded Node.js release key fingerprint mismatch: expected {expected}, got {actual}"
|
||||
)]
|
||||
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
|
||||
EmbeddedKeyFingerprintMismatch {
|
||||
#[error(not(source))]
|
||||
expected: &'static str,
|
||||
#[error(not(source))]
|
||||
actual: String,
|
||||
},
|
||||
|
||||
#[display(
|
||||
"The OpenPGP signature of {url} does not match any trusted Node.js release key. The downloaded Node.js runtime cannot be verified as a genuine release."
|
||||
)]
|
||||
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
|
||||
SignatureInvalid {
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Errors raised by [`pick_file_checksum_from_shasums_file`].
|
||||
///
|
||||
/// Two upstream codes survive the port verbatim — they are the
|
||||
@@ -90,6 +169,41 @@ pub async fn fetch_shasums_file(
|
||||
Ok(parse_shasums_file(&body))
|
||||
}
|
||||
|
||||
/// Fetch a Node.js release's `SHASUMS256.txt` and verify its
|
||||
/// detached OpenPGP signature (`SHASUMS256.txt.sig`) against the
|
||||
/// embedded Node.js release keys before returning the body.
|
||||
pub async fn fetch_verified_node_shasums(
|
||||
http_client: &ThrottledClient,
|
||||
shasums_url: &str,
|
||||
) -> Result<String, FetchVerifiedNodeShasumsError> {
|
||||
let shasums_bytes =
|
||||
fetch_node_shasums_bytes(http_client, shasums_url, "SHASUMS256.txt").await?;
|
||||
let signature_url = format!("{shasums_url}.sig");
|
||||
let signature_bytes =
|
||||
fetch_node_shasums_bytes(http_client, &signature_url, "SHASUMS256.txt.sig").await?;
|
||||
|
||||
if !is_signed_by_trusted_node_release_key(&shasums_bytes, &signature_bytes)? {
|
||||
return Err(FetchVerifiedNodeShasumsError::SignatureInvalid {
|
||||
url: shasums_url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
String::from_utf8(shasums_bytes).map_err(|error| FetchVerifiedNodeShasumsError::InvalidUtf8 {
|
||||
url: shasums_url.to_string(),
|
||||
error: Arc::new(error),
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`fetch_shasums_file`], but first verifies the SHASUMS file's
|
||||
/// detached OpenPGP signature against the Node.js release keys.
|
||||
pub async fn fetch_verified_node_shasums_file(
|
||||
http_client: &ThrottledClient,
|
||||
shasums_url: &str,
|
||||
) -> Result<Vec<ShasumsFileItem>, FetchVerifiedNodeShasumsError> {
|
||||
let body = fetch_verified_node_shasums(http_client, shasums_url).await?;
|
||||
Ok(parse_shasums_file(&body))
|
||||
}
|
||||
|
||||
/// Companion to [`fetch_shasums_file`] that returns the raw body so
|
||||
/// callers can later pick a single row out with
|
||||
/// [`pick_file_checksum_from_shasums_file`].
|
||||
@@ -119,6 +233,78 @@ pub async fn fetch_shasums_file_raw(
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_node_shasums_bytes(
|
||||
http_client: &ThrottledClient,
|
||||
url: &str,
|
||||
what: &'static str,
|
||||
) -> Result<Vec<u8>, FetchVerifiedNodeShasumsError> {
|
||||
let response =
|
||||
http_client.acquire_for_url(url).await.get(url).send().await.map_err(|error| {
|
||||
FetchVerifiedNodeShasumsError::Network {
|
||||
what,
|
||||
url: url.to_string(),
|
||||
error: Arc::new(error),
|
||||
}
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(FetchVerifiedNodeShasumsError::StatusNotOk {
|
||||
what,
|
||||
url: url.to_string(),
|
||||
status: response.status().as_u16(),
|
||||
});
|
||||
}
|
||||
response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| {
|
||||
FetchVerifiedNodeShasumsError::Network {
|
||||
what,
|
||||
url: url.to_string(),
|
||||
error: Arc::new(error),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_signed_by_trusted_node_release_key(
|
||||
content: &[u8],
|
||||
signature_bytes: &[u8],
|
||||
) -> Result<bool, FetchVerifiedNodeShasumsError> {
|
||||
let signature = DetachedSignature::from_bytes(Cursor::new(signature_bytes))
|
||||
.map_err(signature_unreadable)?;
|
||||
for key in trusted_node_release_keys()? {
|
||||
if signature.verify(&key.primary_key, content).is_ok() {
|
||||
return Ok(true);
|
||||
}
|
||||
for subkey in &key.public_subkeys {
|
||||
if signature.verify(subkey, content).is_ok() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn trusted_node_release_keys() -> Result<Vec<SignedPublicKey>, FetchVerifiedNodeShasumsError> {
|
||||
NODE_RELEASE_KEYS.iter().map(read_node_release_key).collect()
|
||||
}
|
||||
|
||||
fn read_node_release_key(
|
||||
trusted_key: &NodeReleaseKey,
|
||||
) -> Result<SignedPublicKey, FetchVerifiedNodeShasumsError> {
|
||||
let (key, _headers) = SignedPublicKey::from_armor_single(trusted_key.armored_key.as_bytes())
|
||||
.map_err(signature_unreadable)?;
|
||||
let actual_fingerprint = key.fingerprint().to_string();
|
||||
let fingerprint_matches = actual_fingerprint.eq_ignore_ascii_case(trusted_key.fingerprint);
|
||||
if !fingerprint_matches {
|
||||
return Err(FetchVerifiedNodeShasumsError::EmbeddedKeyFingerprintMismatch {
|
||||
expected: trusted_key.fingerprint,
|
||||
actual: actual_fingerprint,
|
||||
});
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn signature_unreadable(error: pgp::errors::Error) -> FetchVerifiedNodeShasumsError {
|
||||
FetchVerifiedNodeShasumsError::SignatureUnreadable { error: Arc::new(error) }
|
||||
}
|
||||
|
||||
/// Parse a `SHASUMS256.txt` body into rows.
|
||||
///
|
||||
/// Split out from [`fetch_shasums_file`] so verifier-side code that
|
||||
|
||||
2122
pacquet/crates/crypto-shasums-file/src/node_release_keys.rs
Normal file
2122
pacquet/crates/crypto-shasums-file/src/node_release_keys.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::{
|
||||
PickFileChecksumError, ShasumsFileItem, parse_shasums_file,
|
||||
FetchVerifiedNodeShasumsError, PickFileChecksumError, ShasumsFileItem,
|
||||
fetch_verified_node_shasums, is_signed_by_trusted_node_release_key, parse_shasums_file,
|
||||
pick_file_checksum_from_shasums_file,
|
||||
};
|
||||
|
||||
@@ -96,3 +97,136 @@ be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi";
|
||||
other => panic!("expected Malformed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetches_node_shasums_when_signature_verifies() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let signature = node_22_11_0_signature();
|
||||
let _shasums = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
|
||||
.with_status(200)
|
||||
.with_body(NODE_22_11_0_SHASUMS)
|
||||
.create_async()
|
||||
.await;
|
||||
let _signature = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
|
||||
.with_status(200)
|
||||
.with_body(signature)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = pacquet_network::ThrottledClient::new_for_installs();
|
||||
let body = fetch_verified_node_shasums(
|
||||
&client,
|
||||
&format!("{}/download/release/v22.11.0/SHASUMS256.txt", server.url()),
|
||||
)
|
||||
.await
|
||||
.expect("signature verifies");
|
||||
|
||||
assert_eq!(body, NODE_22_11_0_SHASUMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_node_shasums() {
|
||||
let signature = node_22_11_0_signature();
|
||||
let tampered = NODE_22_11_0_SHASUMS.replacen("1bbf7e63", "00000000", 1);
|
||||
|
||||
assert!(
|
||||
!is_signed_by_trusted_node_release_key(tampered.as_bytes(), &signature)
|
||||
.expect("signature parsed"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_node_shasums_signature_fails() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _shasums = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
|
||||
.with_status(200)
|
||||
.with_body(NODE_22_11_0_SHASUMS)
|
||||
.create_async()
|
||||
.await;
|
||||
let _signature = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
|
||||
.with_status(404)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = pacquet_network::ThrottledClient::new_for_installs();
|
||||
let err = fetch_verified_node_shasums(
|
||||
&client,
|
||||
&format!("{}/download/release/v22.11.0/SHASUMS256.txt", server.url()),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing signature must fail");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
FetchVerifiedNodeShasumsError::StatusNotOk { what: "SHASUMS256.txt.sig", status: 404, .. },
|
||||
));
|
||||
}
|
||||
|
||||
fn node_22_11_0_signature() -> Vec<u8> {
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
|
||||
BASE64_STANDARD.decode(NODE_22_11_0_SIGNATURE_B64).expect("valid base64")
|
||||
}
|
||||
|
||||
// cspell:disable
|
||||
const NODE_22_11_0_SHASUMS: &str = "\
|
||||
1bbf7e632ea55eabf920e8e27bb3e73ca4923eca78a300e5767635e9b2c0c603 node-v22.11.0-aix-ppc64.tar.gz
|
||||
de6cd4db461b6dc3b3eab31a36b58e30d8af074183bcb13ceca6fd162a579ba6 node-v22.11.0-arm64.msi
|
||||
2e89afe6f4e3aa6c7e21c560d8a0453d84807e97850bbb819b998531a22bdfde node-v22.11.0-darwin-arm64.tar.gz
|
||||
c379a90c6aa605b74042a233ddcda4247b347ba5732007d280e44422cc8f9ecb node-v22.11.0-darwin-arm64.tar.xz
|
||||
668d30b9512137b5f5baeef6c1bb4c46efff9a761ba990a034fb6b28b9da2465 node-v22.11.0-darwin-x64.tar.gz
|
||||
ab28d1784625d151e3f608a9412a009118f376118ed842ae643f8c2efdfb0af6 node-v22.11.0-darwin-x64.tar.xz
|
||||
0d42dc3b3377f49e495976dc0e4f5c3a7ffb1d714050d2f247afdbbc0898dae5 node-v22.11.0-headers.tar.gz
|
||||
7eddf759cd3d1a0113c1a0ac7c080e5c0e458bca34a064c62dc8ce613ff5efdd node-v22.11.0-headers.tar.xz
|
||||
27453f7a0dd6b9e6738f1f6ea6a09b102ec7aa484de1e39d6a1c3608ad47aa6a node-v22.11.0-linux-arm64.tar.gz
|
||||
6031d04b98f59ff0f7cb98566f65b115ecd893d3b7870821171708cdbaf7ae6e node-v22.11.0-linux-arm64.tar.xz
|
||||
f85ced095b17e2535859fd2a5641370c3fca12dd72147f93d2696e2909fe1e9d node-v22.11.0-linux-armv7l.tar.gz
|
||||
9de0fdcfb1cccbe03f72f939e4e6f03867aef3da8223f90606cd93757704dae0 node-v22.11.0-linux-armv7l.tar.xz
|
||||
0532965a717d3996302a111703c007dac2763e01795730d488dadbc2fcfac2fa node-v22.11.0-linux-ppc64le.tar.gz
|
||||
d1d49d7d611b104b6d616e18ac439479d8296aa20e3741432de0e85f4735a81e node-v22.11.0-linux-ppc64le.tar.xz
|
||||
64f691400ffe3a84be930e0cb03607d0b95bef122a679f7893d8e2972e90c521 node-v22.11.0-linux-s390x.tar.gz
|
||||
f474ed77d6b13d66d07589aee1c2b9175be4c1b165483e608ac1674643064a99 node-v22.11.0-linux-s390x.tar.xz
|
||||
4f862bab52039835efbe613b532238b6e4dde98d139a34e6923193e073438b13 node-v22.11.0-linux-x64.tar.gz
|
||||
83bf07dd343002a26211cf1fcd46a9d9534219aad42ee02847816940bf610a72 node-v22.11.0-linux-x64.tar.xz
|
||||
8d658eda7699d580ccc268ca8a40ced5aeecef5bb4d19c4187e92eebac5d68ec node-v22.11.0.pkg
|
||||
24e5130fa7bc1eaab218a0c9cb05e03168fa381bb9e3babddc6a11f655799222 node-v22.11.0.tar.gz
|
||||
bbf0297761d53aefda9d7855c57c7d2c272b83a7b5bad4fea9cb29006d8e1d35 node-v22.11.0.tar.xz
|
||||
55b491f3d73fdacf8cf43a2199e824abadda2c43a94780310baa526dc1d679e2 node-v22.11.0-win-arm64.7z
|
||||
b9ff5a6b6ffb68a0ffec82cc5664ed48247dabbd25ee6d129facd2f65a8ca80d node-v22.11.0-win-arm64.zip
|
||||
d2a4fadb1f5e4abc634b6ac16c44cae7c73ffc3dbfe8b92b011d85f2df90f6c1 node-v22.11.0-win-x64.7z
|
||||
905373a059aecaf7f48c1ce10ffbd5334457ca00f678747f19db5ea7d256c236 node-v22.11.0-win-x64.zip
|
||||
ca0a274f1edc90005b1dc7ec22ec55dad1acc21320bc0be853065d69db2a5152 node-v22.11.0-win-x86.7z
|
||||
700e0b1bcaca8b1a04c929ce29b0f07e099b4a34a7facab74fda71764d16f71c node-v22.11.0-win-x86.zip
|
||||
9eea480bd30c98ae11a97cb89a9278235cbbbd03c171ee5e5198bd86b7965b4b node-v22.11.0-x64.msi
|
||||
ab19f02c4b0d9f578928b67d2a652496aa31729a8cc9771ffc9cc6d3b8afe7e3 node-v22.11.0-x86.msi
|
||||
b4e5e2821aeb518c0c55f02d4fcd9182c57f97bcce50341998333dba38e34ea4 win-arm64/node.exe
|
||||
ad65afe5b192644fec9d599c77f0e38a8421d0d7ad2389679882a288c8df444b win-arm64/node.lib
|
||||
0861cf0f1ff6135a21eb26279fc6a6f7dc9d9c0ac926a17553f387c32945eea5 win-arm64/node_pdb.7z
|
||||
f35c2d1a967080b0a1e288b891cb9300a04d0b90042bac8c965c9ebcfc3749bf win-arm64/node_pdb.zip
|
||||
7447c4ece014aa41fb2ff866c993c708e5a8213a00913cc2ac5049ea3ffc230d win-x64/node.exe
|
||||
3581a06b68c4584d146372113eaa8c4d102127222e5041195ba38f185eef419c win-x64/node.lib
|
||||
171d80aeedbe43bd70b3539de6f845a359d8dd97a684df2cbb4f49d8946f4991 win-x64/node_pdb.7z
|
||||
7c3fa0149b17d9ff4b5af2f3e19e768b6ab684a9dd8dcf35ea204a90d3f56903 win-x64/node_pdb.zip
|
||||
e54a4559dafd56562a45b50000831d28ee2f7f1ac4ff98b38165871f31f64ab8 win-x86/node.exe
|
||||
45399070d1d247cf223d12e80d3e638635af24d2f7a4714bc8e38a6a918f162a win-x86/node.lib
|
||||
a78040dbb0e7296eebe90c235091ee46a8a01587a226bf4e5a01f5b399e153d7 win-x86/node_pdb.7z
|
||||
9fb300178536e8243ad55207ee85990731e77299c9e670cec0b54e10dc971713 win-x86/node_pdb.zip
|
||||
";
|
||||
|
||||
const NODE_22_11_0_SIGNATURE_B64: &str = concat!(
|
||||
"iQIzBAABCAAdFiEEyC+jrhy+3Gvka5NgxDzsRcF6uTwFAmcg6xUACgkQxDzsRcF6",
|
||||
"uTyvgg/+J2t1XkjQGIKUiZVKbdWcct6x4F9RblpmlVV4ZMG9F6yMyM4i1znFPCok",
|
||||
"35gCi/gQcF0wR2pE2cpmUEqYFObPf7x7/CEcraZEEqvdve/0r3tBjwUr7MjQXm3",
|
||||
"B3TtPjo6S2f5CfzU+WhvdQrly/Bzlv7bPeGLDp+IKYN/MBDqDqBePs5i5l/Ka2z",
|
||||
"mEwxKu1dAt8/0n+3xNoTtlWSO5zURRZeztn0cx7pHFc4emsLJe7HKgswAaWmF0N",
|
||||
"N+8Rj6efbn//TxLm9LGlLkQGcqwfUmnjwsN7u+4CWRH8Bku2FmnmZLYLJVMjJa4",
|
||||
"mgODI/AtcbuhYxtC2lN/FIZ+/inKWbmh7j2VwqBkz7yc9yATcqWrLbXVlsD/+E4",
|
||||
"4vXtJUPMQCB9M05m1i28isxWEYf6UssKEgB2lHCXsDyYYtH7X6GwznKybnkhWvF",
|
||||
"tKloNwHBgw0ox1wI+uNF5UpI63HlVK3vADFhgwBQ1aOoPPMacefw0xAKwex40Vf",
|
||||
"sntaLBDaGoY454t2xYn7qPCJNtq4fi6CScJqHK3RRH13E17Tk7lfc8IxUY4UwIr",
|
||||
"eIR0oPv1dAmmuCJqLvCJ/mQ0fDzTKPB+1f5d1BPXXBetcHdsZHsRiEbz1R5xzOT",
|
||||
"+meX1EoRTSZliUd/NGx7F9MwzpHrxfEDTJCHbtGwwyfbc6WuzCfRR14riE/g=",
|
||||
);
|
||||
// cspell:enable
|
||||
|
||||
@@ -26,6 +26,7 @@ ssri = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ use std::{
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_crypto_shasums_file::{FetchShasumsFileError, fetch_shasums_file};
|
||||
use pacquet_crypto_shasums_file::{
|
||||
FetchShasumsFileError, FetchVerifiedNodeShasumsError, fetch_shasums_file,
|
||||
fetch_verified_node_shasums_file,
|
||||
};
|
||||
use pacquet_lockfile::{
|
||||
BinaryArchive, BinaryResolution, BinarySpec, LockfileResolution, PlatformAssetResolution,
|
||||
PlatformAssetTarget, VariationsResolution,
|
||||
@@ -65,6 +68,9 @@ pub enum NodeResolverError {
|
||||
#[diagnostic(transparent)]
|
||||
FetchShasumsFile(#[error(source)] FetchShasumsFileError),
|
||||
|
||||
#[diagnostic(transparent)]
|
||||
FetchVerifiedNodeShasums(#[error(source)] FetchVerifiedNodeShasumsError),
|
||||
|
||||
#[display("Failed to parse integrity {integrity} for {file_name}")]
|
||||
#[diagnostic(code(NODE_INTEGRITY_PARSE_FAILED))]
|
||||
ParseIntegrity {
|
||||
@@ -145,7 +151,7 @@ impl NodeResolver {
|
||||
Box::new(NodeResolverError::VersionNotFound { spec: version_spec.to_string() })
|
||||
as ResolveError
|
||||
})?;
|
||||
let variants = self.read_node_assets(&mirror, &version).await?;
|
||||
let variants = self.read_node_assets(&mirror, &version, &parsed.release_channel).await?;
|
||||
let range = if version == version_spec { version.clone() } else { format!("^{version}") };
|
||||
let resolution = LockfileResolution::Variations(VariationsResolution { variants });
|
||||
let manifest = serde_json::json!({
|
||||
@@ -217,12 +223,14 @@ impl NodeResolver {
|
||||
&self,
|
||||
mirror: &str,
|
||||
version: &str,
|
||||
release_channel: &str,
|
||||
) -> Result<Vec<PlatformAssetResolution>, ResolveError> {
|
||||
let mut assets = read_node_assets_from_mirror(
|
||||
&self.http_client,
|
||||
mirror,
|
||||
version,
|
||||
/* musl_only */ false,
|
||||
/* verify_signature */ release_channel == "release",
|
||||
)
|
||||
.await?;
|
||||
if mirror == DEFAULT_NODE_MIRROR_BASE_URL
|
||||
@@ -231,6 +239,7 @@ impl NodeResolver {
|
||||
UNOFFICIAL_NODE_MIRROR_BASE_URL,
|
||||
version,
|
||||
/* musl_only */ true,
|
||||
/* verify_signature */ false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -263,11 +272,18 @@ async fn read_node_assets_from_mirror(
|
||||
node_mirror_base_url: &str,
|
||||
version: &str,
|
||||
musl_only: bool,
|
||||
verify_signature: bool,
|
||||
) -> Result<Vec<PlatformAssetResolution>, ResolveError> {
|
||||
let integrities_url = format!("{node_mirror_base_url}v{version}/SHASUMS256.txt");
|
||||
let items = fetch_shasums_file(http_client, &integrities_url)
|
||||
.await
|
||||
.map_err(|err| Box::new(NodeResolverError::FetchShasumsFile(err)) as ResolveError)?;
|
||||
let items = if verify_signature {
|
||||
fetch_verified_node_shasums_file(http_client, &integrities_url).await.map_err(|err| {
|
||||
Box::new(NodeResolverError::FetchVerifiedNodeShasums(err)) as ResolveError
|
||||
})?
|
||||
} else {
|
||||
fetch_shasums_file(http_client, &integrities_url)
|
||||
.await
|
||||
.map_err(|err| Box::new(NodeResolverError::FetchShasumsFile(err)) as ResolveError)?
|
||||
};
|
||||
let mut assets = Vec::new();
|
||||
for item in items {
|
||||
let Some(parsed) = parse_node_file_name(&item.file_name, version) else { continue };
|
||||
|
||||
@@ -4,7 +4,10 @@ use pacquet_network::ThrottledClient;
|
||||
use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::{NodeResolver, bin_spec_for_platform, parse_node_file_name};
|
||||
use super::{
|
||||
NodeResolver, NodeResolverError, bin_spec_for_platform, parse_node_file_name,
|
||||
read_node_assets_from_mirror,
|
||||
};
|
||||
|
||||
fn resolver() -> NodeResolver {
|
||||
NodeResolver::new(Arc::new(ThrottledClient::new_for_installs()))
|
||||
@@ -101,3 +104,57 @@ fn bin_spec_is_a_named_map() {
|
||||
BinarySpec::Map(BTreeMap::from([("node".to_string(), "node.exe".to_string())])),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_asset_reader_requires_signature_when_requested() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _shasums = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
|
||||
.with_status(200)
|
||||
.with_body(SHASUMS_WITH_ONE_NODE_ASSET)
|
||||
.create_async()
|
||||
.await;
|
||||
let _signature = server
|
||||
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
|
||||
.with_status(404)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = read_node_assets_from_mirror(
|
||||
&ThrottledClient::new_for_installs(),
|
||||
&format!("{}/download/release/", server.url()),
|
||||
"22.11.0",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect_err("stable release assets must require a SHASUMS signature");
|
||||
let err = err.downcast_ref::<NodeResolverError>().expect("NodeResolverError");
|
||||
|
||||
assert!(matches!(err, NodeResolverError::FetchVerifiedNodeShasums(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prerelease_asset_reader_does_not_require_signature() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _shasums = server
|
||||
.mock("GET", "/download/rc/v22.11.0/SHASUMS256.txt")
|
||||
.with_status(200)
|
||||
.with_body(SHASUMS_WITH_ONE_NODE_ASSET)
|
||||
.create_async()
|
||||
.await;
|
||||
let assets = read_node_assets_from_mirror(
|
||||
&ThrottledClient::new_for_installs(),
|
||||
&format!("{}/download/rc/", server.url()),
|
||||
"22.11.0",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("unsigned channels use the raw SHASUMS file");
|
||||
|
||||
assert_eq!(assets.len(), 1);
|
||||
}
|
||||
|
||||
const SHASUMS_WITH_ONE_NODE_ASSET: &str = "\
|
||||
ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e node-v22.11.0-linux-x64.tar.gz
|
||||
";
|
||||
|
||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -301,6 +301,9 @@ catalogs:
|
||||
'@npm/types':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
'@openpgp/web-stream-tools':
|
||||
specifier: 0.3.1
|
||||
version: 0.3.1
|
||||
'@pnpm/byline':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -772,6 +775,9 @@ catalogs:
|
||||
open:
|
||||
specifier: ^7.4.2
|
||||
version: 7.4.2
|
||||
openpgp:
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
p-defer:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2873,10 +2879,16 @@ importers:
|
||||
'@pnpm/fetching.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../fetching/types
|
||||
openpgp:
|
||||
specifier: 'catalog:'
|
||||
version: 6.3.1(@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.3.0
|
||||
'@openpgp/web-stream-tools':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.1(@types/node@25.9.1)(typescript@5.9.3)
|
||||
'@pnpm/crypto.shasums-file':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
@@ -11288,6 +11300,18 @@ packages:
|
||||
resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
'@openpgp/web-stream-tools@0.3.1':
|
||||
resolution: {integrity: sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18.0.0'
|
||||
typescript: '>=4.7'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@package-json/types@0.0.12':
|
||||
resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==}
|
||||
|
||||
@@ -15512,6 +15536,15 @@ packages:
|
||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
openpgp@6.3.1:
|
||||
resolution: {integrity: sha512-7oSPvmlKPojxFoyelT5DWPIAVmqWZh4qU/5pO6bdoShEtRpCw9Sye9IXUQj6EFM3XpgGssqccAr705YtTcLNQw==}
|
||||
engines: {node: '>= 18.0.0', typescript: '>= 5.0.0'}
|
||||
peerDependencies:
|
||||
'@openpgp/web-stream-tools': ~0.3.0
|
||||
peerDependenciesMeta:
|
||||
'@openpgp/web-stream-tools':
|
||||
optional: true
|
||||
|
||||
opt-cli@1.5.1:
|
||||
resolution: {integrity: sha512-iRFQBiQjXZ+LX/8pis04prUhS6FOYcJiZRouofN3rUJEB282b/e0s3jp9vT7aHgXY6TUpgPwu12f0i+qF40Kjw==}
|
||||
hasBin: true
|
||||
@@ -18581,6 +18614,11 @@ snapshots:
|
||||
|
||||
'@npmcli/redact@4.0.0': {}
|
||||
|
||||
'@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3)':
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.1
|
||||
typescript: 5.9.3
|
||||
|
||||
'@package-json/types@0.0.12': {}
|
||||
|
||||
'@pkgr/core@0.3.6': {}
|
||||
@@ -23936,6 +23974,10 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openpgp@6.3.1(@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@openpgp/web-stream-tools': 0.3.1(@types/node@25.9.1)(typescript@5.9.3)
|
||||
|
||||
opt-cli@1.5.1:
|
||||
dependencies:
|
||||
commander: 2.9.0
|
||||
|
||||
@@ -241,6 +241,8 @@ catalog:
|
||||
npm-packlist: 10.0.4
|
||||
object-hash: 3.0.0
|
||||
open: ^7.4.2
|
||||
openpgp: ^6.3.1
|
||||
'@openpgp/web-stream-tools': 0.3.1
|
||||
p-defer: ^4.0.1
|
||||
p-every: ^2.0.0
|
||||
p-filter: ^4.1.0
|
||||
|
||||
Reference in New Issue
Block a user