diff --git a/Cargo.lock b/Cargo.lock index 4d48c55083..19cee152ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3584,6 +3584,7 @@ dependencies = [ "mockito", "node-semver", "owo-colors", + "p256", "pacquet-catalogs-config", "pacquet-cmd-shim", "pacquet-config", diff --git a/Cargo.toml b/Cargo.toml index 99c5ee6cb0..1916ec1a34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ reqwest = { version = "0.13", default-features = false, features = [ "stream", ] } node-semver = { version = "2.2.0" } +p256 = { version = "0.13.2", default-features = false, features = ["ecdsa", "pkcs8", "std"] } pathdiff = { version = "0.2.3" } pipe-trait = { version = "0.4.0" } pgp = { version = "0.19.0", default-features = false } diff --git a/pacquet/crates/cli/Cargo.toml b/pacquet/crates/cli/Cargo.toml index 3a96ccc78a..a5197ea4e0 100644 --- a/pacquet/crates/cli/Cargo.toml +++ b/pacquet/crates/cli/Cargo.toml @@ -58,6 +58,7 @@ indexmap = { workspace = true } node-semver = { workspace = true } miette = { workspace = true } owo-colors.workspace = true +p256 = { workspace = true } pathdiff = { workspace = true } pipe-trait = { workspace = true } rayon = { workspace = true } diff --git a/pacquet/crates/cli/src/cli_args/audit.rs b/pacquet/crates/cli/src/cli_args/audit.rs index 022947f995..c127dfb746 100644 --- a/pacquet/crates/cli/src/cli_args/audit.rs +++ b/pacquet/crates/cli/src/cli_args/audit.rs @@ -8,7 +8,7 @@ use owo_colors::{OwoColorize, Stream}; use pacquet_config::{AuditLevel as ConfigAuditLevel, Config}; use pacquet_lockfile::{ EnvLockfile, ImporterDepVersion, Lockfile, PackageKey, PkgName, ResolvedDependencyMap, - SnapshotDepRef, SnapshotEntry, SpecifierAndResolution, + SnapshotDepRef, SnapshotEntry, SpecifierAndResolution, pick_registry_for_package, }; use pacquet_network::{RetryOpts, send_with_retry}; use pacquet_package_manager::{ResolutionObserver, ResolvedPackageHint, Update}; @@ -26,6 +26,8 @@ use std::{ time::Duration, }; +mod signatures; + const MAX_PATHS_COUNT: usize = 3; const MAX_PATHS_PER_FINDING: usize = 100; @@ -68,7 +70,8 @@ pub struct AuditArgs { #[clap(short = 'i', long)] pub interactive: bool, - /// Audit subcommand. `audit signatures` has not been ported yet. + /// Audit subcommand. The only supported subcommand is `signatures`, + /// which verifies registry signatures for the installed packages. pub params: Vec, } @@ -141,16 +144,22 @@ impl AuditArgs { mut state: State, ) -> miette::Result { if let Some(subcommand) = self.params.first() { - return if subcommand == "signatures" { - Err(miette::miette!( - "`pacquet audit signatures` is not supported yet; registry signature verification has not been ported to pacquet." - )) - } else { - Err(AuditError::UnknownSubcommand { - subcommand: self.params.iter().take(2).cloned().collect::>().join(" "), + if subcommand == "signatures" { + if self.params.len() > 1 { + return Err(AuditError::UnknownSubcommand { + subcommand: self + .params + .iter() + .take(2) + .cloned() + .collect::>() + .join(" "), + } + .into()); } - .into()) - }; + return self.run_signatures(state).await; + } + return Err(AuditError::UnknownSubcommand { subcommand: subcommand.clone() }.into()); } let include = self.dependency_options.include(); @@ -309,6 +318,69 @@ impl AuditArgs { None => Ok(None), } } + + /// Handle `audit signatures`: verify registry signatures for every + /// installed package and print the report. Exit code 1 (via + /// [`AuditOutcome::Vulnerable`]) when any signature is missing or invalid. + /// Ports pnpm's `auditSignatures`. + async fn run_signatures(&self, state: State) -> miette::Result { + let include = self.dependency_options.include(); + let lockfile_dir = state + .manifest + .path() + .parent() + .map_or_else(|| state.manifest.path().to_path_buf(), std::path::Path::to_path_buf); + + let packages = { + let lockfile = state + .lockfile + .get() + .map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?; + let Some(lockfile) = lockfile else { + return Err(AuditError::NoLockfile.into()); + }; + let env_lockfile_dir = state.config.workspace_dir.as_deref().unwrap_or(&lockfile_dir); + let env_lockfile = EnvLockfile::read(env_lockfile_dir) + .map_err(|err| miette::Report::new(err).wrap_err("load the env lockfile"))?; + let audit_request = lockfile_to_audit_request(lockfile, env_lockfile.as_ref(), include); + let registries: HashMap = + state.config.resolved_registries().into_iter().collect(); + audit_request + .request + .iter() + .flat_map(|(name, versions)| { + let registry = pick_registry_for_package(®istries, name, None); + versions.iter().map(move |version| signatures::SignaturePackage { + name: name.clone(), + registry: registry.clone(), + version: version.clone(), + }) + }) + .collect::>() + }; + + if packages.is_empty() { + return Err(AuditError::NoPackages.into()); + } + + let result = + signatures::verify_signatures(&packages, state.config, state.http_client.as_ref()) + .await?; + + let output = if self.json { + serde_json::to_string_pretty(&result).into_diagnostic()? + } else { + signatures::render_signature_verification_result(&result) + }; + print!("{output}"); + let _ = std::io::stdout().flush(); + + Ok(if result.invalid.is_empty() && result.missing.is_empty() { + AuditOutcome::Clean + } else { + AuditOutcome::Vulnerable + }) + } } #[derive(Debug, Display, Error, Diagnostic)] @@ -318,6 +390,10 @@ enum AuditError { #[diagnostic(code(ERR_PNPM_AUDIT_NO_LOCKFILE))] NoLockfile, + #[display("No installed packages found to audit")] + #[diagnostic(code(ERR_PNPM_AUDIT_NO_PACKAGES))] + NoPackages, + #[display("No pnpm-lock.yaml found after update: Cannot report fixed vulnerabilities")] #[diagnostic(code(ERR_PNPM_AUDIT_NO_LOCKFILE))] NoLockfileAfterUpdate, diff --git a/pacquet/crates/cli/src/cli_args/audit/signatures.rs b/pacquet/crates/cli/src/cli_args/audit/signatures.rs new file mode 100644 index 0000000000..f836e9e095 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/audit/signatures.rs @@ -0,0 +1,638 @@ +//! `pacquet audit signatures` — verify ECDSA registry signatures for the +//! installed packages. +//! +//! Ports pnpm's +//! [`@pnpm/deps.security.signatures`](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/deps/security/signatures/src/verifySignatures.ts) +//! and the +//! [`audit signatures` command](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/deps/compliance/commands/src/audit/signatures.ts). +//! +//! For every installed `name@version`, the package's own registry is asked +//! for its signing keys (`/-/npm/v1/keys`) and its full packument. A +//! package is **verified** as soon as one of its `dist.signatures` validates, +//! over the message `name@version:integrity`, against a trusted +//! ECDSA-P256 key. Registries that advertise no signing keys are skipped +//! (there is no trust root to check against); a package whose registry does +//! provide keys but whose signature is absent is **missing**, and one whose +//! signature is present but does not validate is **invalid** — a tamper +//! signal. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + fmt::Write as _, +}; + +use base64::Engine as _; +use owo_colors::{OwoColorize, Stream}; +use p256::{ + ecdsa::{Signature, VerifyingKey, signature::Verifier}, + pkcs8::DecodePublicKey, +}; +use pacquet_config::Config; +use pacquet_network::{ThrottledClient, redact_url_credentials, send_with_retry}; +use serde::{Deserialize, Serialize}; + +use super::{bold, red, retry_opts_from_config, sanitize_response_body}; + +/// One installed package to check, already routed to the registry it was +/// installed from. +pub(super) struct SignaturePackage { + pub name: String, + pub registry: String, + pub version: String, +} + +/// A package that failed (or lacks) signature verification. JSON-serialized +/// in the `--json` report; `integrity`, `reason`, and `resolved` are omitted +/// when absent, matching pnpm's `JSON.stringify` dropping `undefined`. +#[derive(Debug, Serialize)] +pub(super) struct SignatureIssue { + pub name: String, + pub registry: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub integrity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved: Option, +} + +#[derive(Debug, Default, Serialize)] +pub(super) struct SignatureVerificationResult { + pub audited: usize, + pub invalid: Vec, + pub missing: Vec, + pub verified: usize, +} + +#[derive(Debug, Clone, Deserialize)] +struct RegistryKey { + #[serde(default)] + expires: Option, + key: String, + keyid: String, + keytype: String, + scheme: String, +} + +#[derive(Debug, Deserialize)] +struct RegistryKeysResponse { + keys: Vec, +} + +#[derive(Debug, Deserialize)] +struct PackageSignature { + keyid: String, + sig: String, +} + +#[derive(Debug, Deserialize)] +struct Dist { + #[serde(default)] + integrity: Option, + #[serde(default)] + tarball: Option, + #[serde(default)] + signatures: Option, +} + +#[derive(Debug, Deserialize)] +struct PackumentVersion { + #[serde(default)] + dist: Option, +} + +#[derive(Debug, Deserialize)] +struct Packument { + /// Per-version publish times. Kept as raw JSON values (rather than + /// `String`s) because the object also holds `created`/`modified` keys and + /// pnpm never validates the shape — only `versions` is required. + #[serde(default)] + time: BTreeMap, + versions: HashMap, +} + +#[derive(Debug, derive_more::Display, derive_more::Error, miette::Diagnostic)] +#[non_exhaustive] +pub(super) enum SignaturesError { + // `reason` is the registry error already passed through + // `redact_url_credentials`; the raw `reqwest::Error` is not carried as a + // diagnostic source, so embedded `user:pass@` credentials cannot leak via + // its `Display` or the miette cause chain. + #[display("Failed to request the registry keys endpoint (at {url}): {reason}")] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))] + KeysNetwork { url: String, reason: String }, + + #[display("The registry keys endpoint (at {url}) responded with {status}: {body}")] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))] + KeysBadStatus { url: String, status: u16, body: String }, + + #[display( + "The registry keys endpoint (at {url}) returned invalid JSON: {reason}. Response body: {body}" + )] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))] + KeysInvalidJson { url: String, reason: String, body: String }, + + #[display( + "The registry keys endpoint (at {url}) returned an unexpected body. Expected an object with a keys array; got: {body}" + )] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))] + KeysUnexpectedBody { url: String, body: String }, + + /// See [`SignaturesError::KeysNetwork`] for why the error is stored as a + /// pre-redacted string rather than a `reqwest::Error` source. + #[display("Failed to request the packument endpoint (at {url}): {reason}")] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))] + PackumentNetwork { url: String, reason: String }, + + #[display("The packument endpoint (at {url}) responded with {status}: {body}")] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))] + PackumentBadStatus { url: String, status: u16, body: String }, + + #[display( + "The packument endpoint (at {url}) returned invalid JSON: {reason}. Response body: {body}" + )] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))] + PackumentInvalidJson { url: String, reason: String, body: String }, + + #[display( + "The packument endpoint (at {url}) returned an unexpected body. Expected an object with versions; got: {body}" + )] + #[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))] + PackumentUnexpectedBody { url: String, body: String }, +} + +/// Verify registry signatures for every package in `packages`. Keys are +/// fetched once per registry; packuments once per `(registry, name)` and +/// reused across a package's installed versions. A keys-endpoint failure is +/// fatal (no trust root); a packument failure is recorded against just the +/// packages that needed it, mirroring pnpm's per-package `catch`. +pub(super) async fn verify_signatures( + packages: &[SignaturePackage], + config: &Config, + http_client: &ThrottledClient, +) -> Result { + let registries: BTreeSet<&str> = packages.iter().map(|pkg| pkg.registry.as_str()).collect(); + let key_fetches = registries.into_iter().map(|registry| async move { + fetch_registry_keys(registry, config, http_client) + .await + .map(|keys| (registry.to_string(), keys)) + }); + let keys_by_registry: HashMap> = + futures_util::future::try_join_all(key_fetches).await?.into_iter().collect(); + + // Only fetch packuments for registries that advertise signing keys; a + // registry without keys is skipped entirely. + let needed: BTreeSet<(&str, &str)> = packages + .iter() + .filter(|pkg| keys_by_registry.get(&pkg.registry).is_some_and(|keys| !keys.is_empty())) + .map(|pkg| (pkg.registry.as_str(), pkg.name.as_str())) + .collect(); + let packument_fetches = needed.into_iter().map(|(registry, name)| async move { + let result = fetch_packument(name, registry, config, http_client) + .await + .map_err(|err| err.to_string()); + ((registry.to_string(), name.to_string()), result) + }); + let packuments: HashMap<(String, String), Result, String>> = + futures_util::future::join_all(packument_fetches).await.into_iter().collect(); + + let mut result = SignatureVerificationResult::default(); + for pkg in packages { + let Some(keys) = keys_by_registry.get(&pkg.registry).filter(|keys| !keys.is_empty()) else { + continue; + }; + match packuments.get(&(pkg.registry.clone(), pkg.name.clone())) { + Some(Err(reason)) => { + result.invalid.push(issue(pkg, None, None, Some(reason.clone()))); + } + Some(Ok(None)) | None => {} + Some(Ok(Some(packument))) => { + result.audited += 1; + process_version(pkg, packument, keys, &mut result); + } + } + } + + result.invalid.sort_by_key(sort_key); + result.missing.sort_by_key(sort_key); + Ok(result) +} + +fn process_version( + pkg: &SignaturePackage, + packument: &Packument, + keys: &[RegistryKey], + result: &mut SignatureVerificationResult, +) { + let version = packument.versions.get(&pkg.version); + let published_at = + packument.time.get(&pkg.version).and_then(serde_json::Value::as_str).map(str::to_string); + let dist = version.and_then(|version| version.dist.as_ref()); + let integrity = dist.and_then(|dist| dist.integrity.clone()); + let resolved = dist.and_then(|dist| dist.tarball.clone()); + let raw_signatures = dist.and_then(|dist| dist.signatures.as_ref()); + + if raw_signatures.is_some_and(|value| !value.is_array()) { + result.invalid.push(issue(pkg, integrity, resolved, Some(malformed_reason(pkg)))); + return; + } + let mut signatures = Vec::new(); + if let Some(serde_json::Value::Array(elements)) = raw_signatures { + for element in elements { + let Ok(signature) = serde_json::from_value::(element.clone()) else { + result.invalid.push(issue(pkg, integrity, resolved, Some(malformed_reason(pkg)))); + return; + }; + signatures.push(signature); + } + } + + if version.is_none() { + let reason = format!("Missing registry metadata for {}@{}", pkg.name, pkg.version); + result.invalid.push(issue(pkg, None, None, Some(reason))); + return; + } + let Some(integrity) = integrity else { + result.missing.push(issue(pkg, None, resolved, None)); + return; + }; + if signatures.is_empty() { + result.missing.push(issue(pkg, Some(integrity), resolved, None)); + return; + } + + match verify_package_signatures( + pkg, + &integrity, + published_at.as_deref(), + resolved.as_deref(), + &signatures, + keys, + ) { + Some(invalid) => result.invalid.push(invalid), + None => result.verified += 1, + } +} + +/// Returns `None` as soon as one signature validates against a trusted key. +/// Unknown-key, expired-key, and invalid-signature outcomes are recorded but +/// do not on their own fail the package — only the absence of any valid +/// signature does. This keeps a key rotation (multiple signatures in the +/// packument) working and stops a mirror from forcing a failure by appending +/// junk. The surfaced reason prefers an invalid-signature failure (a tamper +/// signal) over the weaker unknown/expired reasons. +fn verify_package_signatures( + pkg: &SignaturePackage, + integrity: &str, + published_at: Option<&str>, + resolved: Option<&str>, + signatures: &[PackageSignature], + keys: &[RegistryKey], +) -> Option { + let message = format!("{}@{}:{integrity}", pkg.name, pkg.version); + let published_time = published_at.and_then(parse_timestamp); + + let mut failures = Vec::new(); + for signature in signatures { + let Some(key) = keys.iter().find(|key| key.keyid == signature.keyid) else { + failures.push(format!( + "{}@{} has a registry signature with keyid {} but no corresponding public key can be found", + pkg.name, pkg.version, signature.keyid, + )); + continue; + }; + // Key expiry is a consistency check, not a security boundary: the + // publish time comes from the same unauthenticated packument as the + // signatures. A missing or unparsable publish time therefore keeps the + // key usable — the signature check below is what gates acceptance. + let expired = match (key.expires.as_deref().and_then(parse_timestamp), published_time) { + (Some(expires), Some(published)) => published >= expires, + _ => false, + }; + if expired { + failures.push(format!( + "{}@{} has a registry signature with keyid {} but the corresponding public key has expired {}", + pkg.name, + pkg.version, + signature.keyid, + key.expires.as_deref().unwrap_or_default(), + )); + continue; + } + if verify_one(&key.key, &message, &signature.sig) { + return None; + } + failures.push(format!( + "{}@{} has an invalid registry signature with keyid {}", + pkg.name, pkg.version, signature.keyid, + )); + } + + Some(issue( + pkg, + Some(integrity.to_string()), + resolved.map(str::to_string), + Some(most_telling_failure(pkg, &failures)), + )) +} + +/// Verify one base64 ECDSA-P256 signature over `message` against a base64 +/// SPKI public key. Any malformed key material or signature bytes count as a +/// non-match rather than an error, so one bad key can't abort the audit. +fn verify_one(public_key_base64: &str, message: &str, signature_base64: &str) -> bool { + let engine = base64::engine::general_purpose::STANDARD; + let Ok(key_der) = engine.decode(public_key_base64) else { return false }; + let Ok(verifying_key) = VerifyingKey::from_public_key_der(&key_der) else { return false }; + let Ok(signature_der) = engine.decode(signature_base64) else { return false }; + let Ok(signature) = Signature::from_der(&signature_der) else { return false }; + verifying_key.verify(message.as_bytes(), &signature).is_ok() +} + +fn most_telling_failure(pkg: &SignaturePackage, failures: &[String]) -> String { + if failures.is_empty() { + return format!( + "{}@{} has no registry signature from a trusted key", + pkg.name, pkg.version, + ); + } + failures + .iter() + .find(|reason| reason.contains("invalid registry signature")) + .cloned() + .unwrap_or_else(|| failures[0].clone()) +} + +fn issue( + pkg: &SignaturePackage, + integrity: Option, + resolved: Option, + reason: Option, +) -> SignatureIssue { + SignatureIssue { + name: pkg.name.clone(), + registry: pkg.registry.clone(), + version: pkg.version.clone(), + integrity, + reason, + resolved, + } +} + +fn malformed_reason(pkg: &SignaturePackage) -> String { + format!("Malformed registry signatures metadata for {}@{}", pkg.name, pkg.version) +} + +fn sort_key(issue: &SignatureIssue) -> String { + format!("{}@{}", issue.name, issue.version) +} + +/// Parse an ISO-8601 / RFC-3339 timestamp to epoch milliseconds, returning +/// `None` when it can't be parsed (mirroring JS `Date.parse` yielding `NaN`, +/// which then compares false). +fn parse_timestamp(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value).ok().map(|datetime| datetime.timestamp_millis()) +} + +async fn fetch_registry_keys( + registry: &str, + config: &Config, + http_client: &ThrottledClient, +) -> Result, SignaturesError> { + let registry_url = with_trailing_slash(registry); + let keys_url = format!("{registry_url}-/npm/v1/keys"); + let display_url = redact_url_credentials(&keys_url); + let authorization = config.auth_headers.for_url(®istry_url); + // Keep the throttle guard alive until the body is fully read; dropping it + // before `response.text()` would release the concurrency permit while the + // socket is still draining (see [`send_with_retry`]). + let (_guard, response) = + send_with_retry(http_client, &keys_url, retry_opts_from_config(config), |client| { + let mut request = client.get(&keys_url).header("accept", "application/json"); + if let Some(value) = &authorization { + request = request.header("authorization", value); + } + request + }) + .await + .map_err(|source| SignaturesError::KeysNetwork { + url: display_url.clone(), + reason: redact_url_credentials(&source.to_string()), + })?; + + let status = response.status().as_u16(); + let body = response.text().await.map_err(|source| SignaturesError::KeysNetwork { + url: display_url.clone(), + reason: redact_url_credentials(&source.to_string()), + })?; + // npm registries answer 404 (no signing) and 400 the same way: there is no + // trust root, so the registry's packages are simply not audited. + if status == 404 || status == 400 { + return Ok(Vec::new()); + } + if status != 200 { + return Err(SignaturesError::KeysBadStatus { + url: display_url, + status, + body: sanitize_response_body(&body), + }); + } + + let value: serde_json::Value = + serde_json::from_str(&body).map_err(|err| SignaturesError::KeysInvalidJson { + url: display_url.clone(), + reason: err.to_string(), + body: sanitize_response_body(&body), + })?; + let parsed: RegistryKeysResponse = + serde_json::from_value(value.clone()).map_err(|_| SignaturesError::KeysUnexpectedBody { + url: display_url, + body: sanitize_response_body(&value.to_string()), + })?; + + // npm registry signing uses ECDSA P-256 keys; provenance attestations are + // handled separately and intentionally ignored here. + Ok(parsed + .keys + .into_iter() + .filter(|key| key.keytype == "ecdsa-sha2-nistp256" && key.scheme == "ecdsa-sha2-nistp256") + .collect()) +} + +async fn fetch_packument( + name: &str, + registry: &str, + config: &Config, + http_client: &ThrottledClient, +) -> Result, SignaturesError> { + let registry_url = with_trailing_slash(registry); + let packument_url = format!("{registry_url}{}", encode_package_name(name)); + let display_url = redact_url_credentials(&packument_url); + let authorization = config.auth_headers.for_url(®istry_url); + // Hold the throttle guard until the body is read; see `fetch_registry_keys`. + let (_guard, response) = + send_with_retry(http_client, &packument_url, retry_opts_from_config(config), |client| { + let mut request = client.get(&packument_url).header("accept", "application/json"); + if let Some(value) = &authorization { + request = request.header("authorization", value); + } + request + }) + .await + .map_err(|source| SignaturesError::PackumentNetwork { + url: display_url.clone(), + reason: redact_url_credentials(&source.to_string()), + })?; + + let status = response.status().as_u16(); + let body = response.text().await.map_err(|source| SignaturesError::PackumentNetwork { + url: display_url.clone(), + reason: redact_url_credentials(&source.to_string()), + })?; + if status == 404 { + return Ok(None); + } + if status != 200 { + return Err(SignaturesError::PackumentBadStatus { + url: display_url, + status, + body: sanitize_response_body(&body), + }); + } + + let value: serde_json::Value = + serde_json::from_str(&body).map_err(|err| SignaturesError::PackumentInvalidJson { + url: display_url.clone(), + reason: err.to_string(), + body: sanitize_response_body(&body), + })?; + let parsed: Packument = serde_json::from_value(value.clone()).map_err(|_| { + SignaturesError::PackumentUnexpectedBody { + url: display_url, + body: sanitize_response_body(&value.to_string()), + } + })?; + Ok(Some(parsed)) +} + +fn with_trailing_slash(registry: &str) -> String { + if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") } +} + +/// Percent-encode a package name for a packument URL, matching pnpm's +/// `toUri`: a scoped name keeps its leading `@` and encodes the rest (so the +/// `/` becomes `%2F`), an unscoped name is encoded whole. +fn encode_package_name(name: &str) -> String { + match name.strip_prefix('@') { + Some(rest) => format!("@{}", encode_uri_component(rest)), + None => encode_uri_component(name), + } +} + +/// Port of JavaScript `encodeURIComponent`: every UTF-8 byte outside the +/// unreserved set is percent-encoded. +fn encode_uri_component(input: &str) -> String { + const UNRESERVED: &[u8] = b"-_.!~*'()"; + let mut output = String::with_capacity(input.len()); + for &byte in input.as_bytes() { + if byte.is_ascii_alphanumeric() || UNRESERVED.contains(&byte) { + output.push(byte as char); + } else { + write!(output, "%{byte:02X}").expect("writing to a String never fails"); + } + } + output +} + +pub(super) fn render_signature_verification_result(result: &SignatureVerificationResult) -> String { + let mut lines: Vec = Vec::new(); + lines.push(format!("audited {} {}", result.audited, plural(result.audited, "package"))); + lines.push(String::new()); + + if result.verified > 0 { + lines.push(format!( + "{} {} {} registry {}", + result.verified, + if result.verified == 1 { "package has a" } else { "packages have" }, + bold("verified"), + plural(result.verified, "signature"), + )); + lines.push(String::new()); + } + + if !result.missing.is_empty() { + let count = result.missing.len(); + lines.push(format!( + "{count} {} {} registry {} but the registry is providing signing keys:", + if count == 1 { "package is" } else { "packages are" }, + bright_red("missing"), + plural(count, "signature"), + )); + lines.push(String::new()); + lines.push(issue_table(&result.missing, false)); + lines.push(String::new()); + } + + if !result.invalid.is_empty() { + let count = result.invalid.len(); + lines.push(format!( + "{count} {} {} registry {}:", + if count == 1 { "package has an" } else { "packages have" }, + bright_red("invalid"), + plural(count, "signature"), + )); + lines.push(String::new()); + lines.push(issue_table(&result.invalid, true)); + lines.push(String::new()); + lines.push( + if count == 1 { + "Someone might have tampered with this package since it was published on the registry!" + } else { + "Someone might have tampered with these packages since they were published on the registry!" + } + .to_string(), + ); + lines.push(String::new()); + } + + if result.audited == 0 + && result.invalid.is_empty() + && result.missing.is_empty() + && result.verified == 0 + { + lines.push("No dependencies were installed from a registry with signing keys".to_string()); + lines.push(String::new()); + } + + lines.join("\n") +} + +fn issue_table(issues: &[SignatureIssue], with_reason: bool) -> String { + use tabled::{builder::Builder, settings::Style}; + + let mut builder = Builder::default(); + for issue in issues { + let package = red(&format!("{}@{}", issue.name, issue.version)); + if with_reason { + let reason = + issue.reason.clone().unwrap_or_else(|| "Invalid registry signature".to_string()); + builder.push_record(vec![package, issue.registry.clone(), reason]); + } else { + builder.push_record(vec![package, issue.registry.clone()]); + } + } + let mut table = builder.build(); + table.with(Style::modern()); + table.to_string() +} + +fn plural(count: usize, word: &str) -> String { + if count == 1 { word.to_string() } else { format!("{word}s") } +} + +fn bright_red(text: &str) -> String { + text.if_supports_color(Stream::Stdout, |t| t.bright_red()).to_string() +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/cli/src/cli_args/audit/signatures/tests.rs b/pacquet/crates/cli/src/cli_args/audit/signatures/tests.rs new file mode 100644 index 0000000000..7bc11ff10e --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/audit/signatures/tests.rs @@ -0,0 +1,156 @@ +use super::{ + PackageSignature, RegistryKey, SignaturePackage, SignatureVerificationResult, + encode_package_name, parse_timestamp, render_signature_verification_result, verify_one, + verify_package_signatures, +}; +use base64::Engine as _; +use p256::ecdsa::SigningKey; + +fn signing_key() -> SigningKey { + SigningKey::from_slice(&[0x42; 32]).expect("valid P-256 scalar") +} + +fn public_key_b64(key: &SigningKey) -> String { + use p256::pkcs8::EncodePublicKey; + let der = key.verifying_key().to_public_key_der().expect("encode SPKI"); + base64::engine::general_purpose::STANDARD.encode(der.as_bytes()) +} + +fn sign_b64(key: &SigningKey, message: &str) -> String { + use p256::ecdsa::{Signature, signature::Signer}; + let signature: Signature = key.sign(message.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(signature.to_der().as_bytes()) +} + +fn package() -> SignaturePackage { + SignaturePackage { + name: "foo".to_string(), + registry: "https://registry.example.com/".to_string(), + version: "1.0.0".to_string(), + } +} + +fn ecdsa_key(key: &SigningKey, keyid: &str, expires: Option<&str>) -> RegistryKey { + RegistryKey { + expires: expires.map(str::to_string), + key: public_key_b64(key), + keyid: keyid.to_string(), + keytype: "ecdsa-sha2-nistp256".to_string(), + scheme: "ecdsa-sha2-nistp256".to_string(), + } +} + +fn signature(keyid: &str, sig: &str) -> PackageSignature { + PackageSignature { keyid: keyid.to_string(), sig: sig.to_string() } +} + +#[test] +fn verify_one_accepts_only_the_signed_message() { + let key = signing_key(); + let public = public_key_b64(&key); + let message = "foo@1.0.0:sha512-abc"; + + assert!(verify_one(&public, message, &sign_b64(&key, message))); + assert!(!verify_one(&public, message, &sign_b64(&key, "foo@1.0.0:other"))); + assert!(!verify_one("not base64 ~~~", message, "also not base64")); +} + +#[test] +fn valid_signature_verifies() { + let key = signing_key(); + let package = package(); + let integrity = "sha512-abc"; + let message = format!("{}@{}:{integrity}", package.name, package.version); + let signatures = vec![signature("k1", &sign_b64(&key, &message))]; + let keys = vec![ecdsa_key(&key, "k1", None)]; + + assert!( + verify_package_signatures(&package, integrity, None, None, &signatures, &keys).is_none(), + ); +} + +#[test] +fn unknown_key_yields_an_unknown_key_reason() { + let package = package(); + let signatures = vec![signature("nope", "AA==")]; + + let issue = + verify_package_signatures(&package, "sha512-abc", None, None, &signatures, &[]).unwrap(); + let reason = issue.reason.unwrap(); + assert!(reason.contains("no corresponding public key"), "{reason}"); +} + +#[test] +fn invalid_signature_reason_is_preferred_over_unknown_key() { + let key = signing_key(); + let package = package(); + let integrity = "sha512-abc"; + let tampered = sign_b64(&key, "foo@1.0.0:tampered"); + let signatures = vec![signature("missing", "AA=="), signature("k1", &tampered)]; + let keys = vec![ecdsa_key(&key, "k1", None)]; + + let issue = + verify_package_signatures(&package, integrity, None, None, &signatures, &keys).unwrap(); + let reason = issue.reason.unwrap(); + assert!(reason.contains("invalid registry signature"), "{reason}"); +} + +#[test] +fn key_expiry_gates_only_when_published_after_expiry() { + let key = signing_key(); + let package = package(); + let integrity = "sha512-abc"; + let message = format!("foo@1.0.0:{integrity}"); + let signatures = vec![signature("k1", &sign_b64(&key, &message))]; + let keys = vec![ecdsa_key(&key, "k1", Some("2020-01-01T00:00:00.000Z"))]; + + let published_after = verify_package_signatures( + &package, + integrity, + Some("2021-01-01T00:00:00.000Z"), + None, + &signatures, + &keys, + ); + assert!(published_after.unwrap().reason.unwrap().contains("expired")); + + assert!( + verify_package_signatures( + &package, + integrity, + Some("2019-01-01T00:00:00.000Z"), + None, + &signatures, + &keys + ) + .is_none(), + "a key not yet expired at publish time stays usable", + ); + assert!( + verify_package_signatures(&package, integrity, None, None, &signatures, &keys).is_none(), + "a missing publish time keeps the key usable", + ); +} + +#[test] +fn encode_package_name_matches_encode_uri_component() { + assert_eq!(encode_package_name("lodash"), "lodash"); + assert_eq!(encode_package_name("a.b-c_d"), "a.b-c_d"); + assert_eq!(encode_package_name("@scope/pkg"), "@scope%2Fpkg"); +} + +#[test] +fn parse_timestamp_accepts_iso_and_rejects_garbage() { + assert!(parse_timestamp("2020-01-01T00:00:00.000Z").is_some()); + assert!(parse_timestamp("nonsense").is_none()); +} + +#[test] +fn render_announces_absence_of_signing_keys() { + let output = render_signature_verification_result(&SignatureVerificationResult::default()); + assert!(output.contains("audited 0 packages"), "{output}"); + assert!( + output.contains("No dependencies were installed from a registry with signing keys"), + "{output}", + ); +} diff --git a/pacquet/crates/cli/tests/audit.rs b/pacquet/crates/cli/tests/audit.rs index 1256cb2c31..dff64443f0 100644 --- a/pacquet/crates/cli/tests/audit.rs +++ b/pacquet/crates/cli/tests/audit.rs @@ -439,14 +439,219 @@ fn audit_defaults_to_low_and_ignores_info_for_exit_code() { } #[test] -fn audit_signatures_is_reported_as_unsupported() { +fn audit_signatures_reports_verified_packages() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let key = signing_key(); + let integrity = "sha512-abc"; + let signature = sign_b64(&key, &format!("signed-pkg@1.0.0:{integrity}")); + let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create(); + let packument_mock = registry + .mock("GET", "/signed-pkg") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(packument_body("signed-pkg", "1.0.0", integrity, &signatures_json(&signature))) + .create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_success(&output); + let out = stdout(&output); + assert!(out.contains("audited 1 package"), "{out}"); + assert!(out.contains("1 package has a verified registry signature"), "{out}"); + keys_mock.assert(); + packument_mock.assert(); +} + +#[test] +fn audit_signatures_json_reports_counts() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let key = signing_key(); + let integrity = "sha512-abc"; + let signature = sign_b64(&key, &format!("signed-pkg@1.0.0:{integrity}")); + let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create(); + let packument_mock = registry + .mock("GET", "/signed-pkg") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(packument_body("signed-pkg", "1.0.0", integrity, &signatures_json(&signature))) + .create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet + .arg("audit") + .arg("signatures") + .arg("--json") + .output() + .expect("run audit signatures"); + + assert_success(&output); + let report: serde_json::Value = + serde_json::from_str(&stdout(&output)).expect("signatures JSON"); + assert_eq!(report["audited"], 1); + assert_eq!(report["verified"], 1); + assert_eq!(report["invalid"].as_array().expect("invalid array").len(), 0); + assert_eq!(report["missing"].as_array().expect("missing array").len(), 0); + keys_mock.assert(); + packument_mock.assert(); +} + +#[test] +fn audit_signatures_flags_missing_signature() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let key = signing_key(); + let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create(); + let packument_mock = registry + .mock("GET", "/signed-pkg") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(packument_body("signed-pkg", "1.0.0", "sha512-abc", "[]")) + .create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_eq!(output.status.code(), Some(1), "missing signatures should exit 1"); + let out = stdout(&output); + assert!(out.contains("missing registry signature"), "{out}"); + assert!(out.contains("signed-pkg@1.0.0"), "{out}"); + keys_mock.assert(); + packument_mock.assert(); +} + +#[test] +fn audit_signatures_flags_invalid_signature() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let key = signing_key(); + // Sign a different integrity than the packument advertises: the signature + // is well-formed but will not validate over the published bytes. + let signature = sign_b64(&key, "signed-pkg@1.0.0:sha512-tampered"); + let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create(); + let packument_mock = registry + .mock("GET", "/signed-pkg") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(packument_body( + "signed-pkg", + "1.0.0", + "sha512-abc", + &signatures_json(&signature), + )) + .create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_eq!(output.status.code(), Some(1), "invalid signatures should exit 1"); + let out = stdout(&output); + assert!(out.contains("invalid registry signature"), "{out}"); + assert!(out.contains("Someone might have tampered"), "{out}"); + keys_mock.assert(); + packument_mock.assert(); +} + +#[test] +fn audit_signatures_skips_registry_without_signing_keys() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let keys_mock = + registry.mock("GET", "/-/npm/v1/keys").with_status(404).with_body("not found").create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_success(&output); + let out = stdout(&output); + assert!(out.contains("audited 0 packages"), "{out}"); + assert!( + out.contains("No dependencies were installed from a registry with signing keys"), + "{out}", + ); + keys_mock.assert(); +} + +#[test] +fn audit_signatures_fails_when_keys_endpoint_errors() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let keys_mock = registry + .mock("GET", "/-/npm/v1/keys") + .with_status(500) + .with_body("boom \u{1b}[31m\n") + .create(); + write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_failure(&output); + let stderr = stderr(&output); + assert!(stderr.contains("ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL"), "stderr:\n{stderr}"); + assert!(stderr.contains("responded with 500"), "stderr:\n{stderr}"); + // The attacker-controlled registry body is escaped before it reaches the + // terminal: the raw ESC byte must not survive. + assert!(stderr.contains(r"boom \u{1b}[31m\u{a}"), "stderr:\n{stderr}"); + assert!(!stderr.contains('\u{1b}'), "stderr:\n{stderr}"); + keys_mock.assert(); +} + +#[test] +fn audit_signatures_redacts_registry_credentials_on_network_error() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + // A registry with embedded credentials pointed at a closed port: the keys + // fetch fails at the transport layer, and the resulting error must not leak + // the `user:pass@` userinfo into stderr. + write_signatures_workspace(&workspace, "https://user:pass@127.0.0.1:1", "signed-pkg"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_failure(&output); + let stderr = stderr(&output); + assert!(stderr.contains("ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL"), "stderr:\n{stderr}"); + assert!(!stderr.contains("user:pass"), "credentials leaked into stderr:\n{stderr}"); + assert!(!stderr.contains("pass@"), "credentials leaked into stderr:\n{stderr}"); +} + +#[test] +fn audit_signatures_errors_when_no_packages() { + let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); + fs::write(workspace.join(".npmrc"), "registry=https://registry.npmjs.org/\n") + .expect("write .npmrc"); + fs::write(workspace.join("pnpm-workspace.yaml"), "fetchRetries: 0\n") + .expect("write workspace manifest"); + write_minimal_manifest(&workspace); + fs::write( + workspace.join("pnpm-lock.yaml"), + " +lockfileVersion: '9.0' + +importers: + + .: {} +", + ) + .expect("write lockfile"); + + let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures"); + + assert_failure(&output); + assert!(stderr(&output).contains("ERR_PNPM_AUDIT_NO_PACKAGES"), "stderr:\n{}", stderr(&output)); +} + +#[test] +fn audit_signatures_rejects_extra_subcommand_argument() { let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init(); write_minimal_manifest(&workspace); - let output = pacquet.arg("audit").arg("signatures").output().expect("run pacquet audit"); + let output = + pacquet.arg("audit").arg("signatures").arg("extra").output().expect("run pacquet audit"); assert_failure(&output); - assert!(stderr(&output).contains("not supported yet")); + assert!(stderr(&output).contains("ERR_PNPM_AUDIT_UNKNOWN_SUBCOMMAND")); + assert!(stderr(&output).contains("Unknown audit subcommand: signatures extra")); } #[test] @@ -796,6 +1001,79 @@ fn write_minimal_manifest(workspace: &Path) { .expect("write package.json"); } +const SIGNATURE_KEYID: &str = "SHA256:test"; + +fn signing_key() -> p256::ecdsa::SigningKey { + p256::ecdsa::SigningKey::from_slice(&[0x42; 32]).expect("valid P-256 scalar") +} + +fn public_key_b64(key: &p256::ecdsa::SigningKey) -> String { + use base64::Engine as _; + use p256::pkcs8::EncodePublicKey; + let der = key.verifying_key().to_public_key_der().expect("encode SPKI"); + base64::engine::general_purpose::STANDARD.encode(der.as_bytes()) +} + +fn sign_b64(key: &p256::ecdsa::SigningKey, message: &str) -> String { + use base64::Engine as _; + use p256::ecdsa::{Signature, signature::Signer}; + let signature: Signature = key.sign(message.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(signature.to_der().as_bytes()) +} + +fn keys_mock(registry: &mut mockito::Server, public_key_b64: &str) -> mockito::Mock { + registry + .mock("GET", "/-/npm/v1/keys") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"keys":[{{"expires":null,"keyid":"{SIGNATURE_KEYID}","keytype":"ecdsa-sha2-nistp256","scheme":"ecdsa-sha2-nistp256","key":"{public_key_b64}"}}]}}"#, + )) +} + +fn signatures_json(signature_b64: &str) -> String { + format!(r#"[{{"keyid":"{SIGNATURE_KEYID}","sig":"{signature_b64}"}}]"#) +} + +fn packument_body(name: &str, version: &str, integrity: &str, signatures_json: &str) -> String { + format!( + r#"{{"name":"{name}","versions":{{"{version}":{{"name":"{name}","version":"{version}","dist":{{"integrity":"{integrity}","tarball":"https://example.com/{name}-{version}.tgz","signatures":{signatures_json}}}}}}},"time":{{"{version}":"2020-01-01T00:00:00.000Z"}}}}"#, + ) +} + +fn write_signatures_workspace(workspace: &Path, registry_url: &str, name: &str) { + fs::write(workspace.join(".npmrc"), format!("registry={registry_url}/\n")) + .expect("write .npmrc"); + fs::write(workspace.join("pnpm-workspace.yaml"), "fetchRetries: 0\n") + .expect("write workspace manifest"); + fs::write( + workspace.join("package.json"), + format!(r#"{{"name":"sig-test","version":"1.0.0","dependencies":{{"{name}":"1.0.0"}}}}"#), + ) + .expect("write package.json"); + fs::write( + workspace.join("pnpm-lock.yaml"), + format!( + " +lockfileVersion: '9.0' + +importers: + + .: + dependencies: + {name}: + specifier: '1.0.0' + version: '1.0.0' + +snapshots: + + {name}@1.0.0: {{}} +", + ), + ) + .expect("write lockfile"); +} + fn nerf(registry_url: &str) -> &str { registry_url.strip_prefix("http:").expect("mockito registry uses http") }