summaryrefslogtreecommitdiff
path: root/cli/tools/registry/mod.rs
diff options
context:
space:
mode:
authorDivy Srivastava <dj.srivastava23@gmail.com>2024-02-28 07:58:02 +0530
committerGitHub <noreply@github.com>2024-02-28 07:58:02 +0530
commit9b5d2f8c1bae498d78400c8e9263bcae6e521adf (patch)
tree69453f9be9fc65774f3087bb986409aadee5acb4 /cli/tools/registry/mod.rs
parente9fe71acb53c8856754ef892c463253cb96087ce (diff)
feat(publish): provenance attestation (#22573)
Supply chain security for JSR. ``` $ deno publish --provenance Successfully published @divy/test_provenance@0.0.3 Provenance transparency log available at https://search.sigstore.dev/?logIndex=73657418 ``` 0. Package has been published. 1. Fetches the version manifest and verifies it's matching with uploaded files and exports. 2. Builds the attestation SLSA payload using Github actions env. 3. Creates an ephemeral key pair for signing the github token (aud=sigstore) and DSSE pre authentication tag. 4. Requests a X.509 signing certificate from Fulcio using the challenge and ephemeral public key PEM. 5. Prepares a DSSE envelop for Rekor to witness. Posts an intoto entry to Rekor and gets back the transparency log index. 6. Builds the provenance bundle and posts it to JSR.
Diffstat (limited to 'cli/tools/registry/mod.rs')
-rw-r--r--cli/tools/registry/mod.rs247
1 files changed, 243 insertions, 4 deletions
diff --git a/cli/tools/registry/mod.rs b/cli/tools/registry/mod.rs
index eadd0e44d..e01c1563b 100644
--- a/cli/tools/registry/mod.rs
+++ b/cli/tools/registry/mod.rs
@@ -15,11 +15,13 @@ use deno_core::error::AnyError;
use deno_core::futures::FutureExt;
use deno_core::serde_json;
use deno_core::serde_json::json;
+use deno_core::serde_json::Value;
use deno_core::unsync::JoinSet;
use deno_runtime::deno_fetch::reqwest;
use deno_terminal::colors;
use import_map::ImportMap;
use lsp_types::Url;
+use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
@@ -48,6 +50,7 @@ mod auth;
mod diagnostics;
mod graph;
mod paths;
+mod provenance;
mod publish_order;
mod tar;
mod unfurl;
@@ -73,6 +76,7 @@ struct PreparedPublishPackage {
version: String,
tarball: PublishableTarball,
config: String,
+ exports: HashMap<String, String>,
}
impl PreparedPublishPackage {
@@ -161,6 +165,18 @@ async fn prepare_publish(
package: name_no_scope.to_string(),
version: version.to_string(),
tarball,
+ exports: match &deno_json.json.exports {
+ Some(Value::Object(exports)) => exports
+ .into_iter()
+ .map(|(k, v)| (k.to_string(), v.as_str().unwrap().to_string()))
+ .collect(),
+ Some(Value::String(exports)) => {
+ let mut map = HashMap::new();
+ map.insert(".".to_string(), exports.to_string());
+ map
+ }
+ _ => HashMap::new(),
+ },
// the config file is always at the root of a publishing dir,
// so getting the file name is always correct
config: config_path
@@ -454,6 +470,7 @@ async fn perform_publish(
mut publish_order_graph: PublishOrderGraph,
mut prepared_package_by_name: HashMap<String, Rc<PreparedPublishPackage>>,
auth_method: AuthMethod,
+ provenance: bool,
) -> Result<(), AnyError> {
let client = http_client.client()?;
let registry_api_url = jsr_api_url().to_string();
@@ -514,6 +531,7 @@ async fn perform_publish(
&registry_api_url,
&registry_url,
&authorization,
+ provenance,
)
.await
.with_context(|| format!("Failed to publish {}", display_name))?;
@@ -540,6 +558,7 @@ async fn publish_package(
registry_api_url: &str,
registry_url: &str,
authorization: &str,
+ provenance: bool,
) -> Result<(), AnyError> {
let client = http_client.client()?;
println!(
@@ -645,6 +664,52 @@ async fn publish_package(
package.package,
package.version
);
+
+ if provenance {
+ // Get the version manifest from JSR
+ let meta_url = jsr_url().join(&format!(
+ "@{}/{}/{}_meta.json",
+ package.scope, package.package, package.version
+ ))?;
+
+ let meta_bytes = client.get(meta_url).send().await?.bytes().await?;
+
+ if std::env::var("DISABLE_JSR_MANIFEST_VERIFICATION_FOR_TESTING").is_err() {
+ verify_version_manifest(&meta_bytes, &package)?;
+ }
+
+ let subject = provenance::Subject {
+ name: format!(
+ "pkg:jsr/@{}/{}@{}",
+ package.scope, package.package, package.version
+ ),
+ digest: provenance::SubjectDigest {
+ sha256: hex::encode(sha2::Sha256::digest(&meta_bytes)),
+ },
+ };
+ let bundle = provenance::generate_provenance(subject).await?;
+
+ let tlog_entry = &bundle.verification_material.tlog_entries[0];
+ println!("{}",
+ colors::green(format!(
+ "Provenance transparency log available at https://search.sigstore.dev/?logIndex={}",
+ tlog_entry.log_index
+ ))
+ );
+
+ // Submit bundle to JSR
+ let provenance_url = format!(
+ "{}scopes/{}/packages/{}/versions/{}/provenance",
+ registry_api_url, package.scope, package.package, package.version
+ );
+ client
+ .post(provenance_url)
+ .header(reqwest::header::AUTHORIZATION, authorization)
+ .json(&json!({ "bundle": bundle }))
+ .send()
+ .await?;
+ }
+
println!(
"{}",
colors::gray(format!(
@@ -826,13 +891,12 @@ pub async fn publish(
Arc::new(ImportMap::new(Url::parse("file:///dev/null").unwrap()))
});
+ let directory_path = cli_factory.cli_options().initial_cwd();
+
let mapped_resolver = Arc::new(MappedSpecifierResolver::new(
Some(import_map),
cli_factory.package_json_deps_provider().clone(),
));
-
- let directory_path = cli_factory.cli_options().initial_cwd();
-
let cli_options = cli_factory.cli_options();
let Some(config_file) = cli_options.maybe_config_file() else {
bail!(
@@ -878,6 +942,181 @@ pub async fn publish(
prepared_data.publish_order_graph,
prepared_data.package_by_name,
auth_method,
+ publish_flags.provenance,
)
- .await
+ .await?;
+
+ Ok(())
+}
+
+#[derive(Deserialize)]
+struct ManifestEntry {
+ checksum: String,
+}
+
+#[derive(Deserialize)]
+struct VersionManifest {
+ manifest: HashMap<String, ManifestEntry>,
+ exports: HashMap<String, String>,
+}
+
+fn verify_version_manifest(
+ meta_bytes: &[u8],
+ package: &PreparedPublishPackage,
+) -> Result<(), AnyError> {
+ let manifest = serde_json::from_slice::<VersionManifest>(meta_bytes)?;
+ // Check that nothing was removed from the manifest.
+ if manifest.manifest.len() != package.tarball.files.len() {
+ bail!(
+ "Mismatch in the number of files in the manifest: expected {}, got {}",
+ package.tarball.files.len(),
+ manifest.manifest.len()
+ );
+ }
+
+ for (path, entry) in manifest.manifest {
+ // Verify each path with the files in the tarball.
+ let file = package
+ .tarball
+ .files
+ .iter()
+ .find(|f| f.path_str == path.as_str());
+
+ if let Some(file) = file {
+ if file.hash != entry.checksum {
+ bail!(
+ "Checksum mismatch for {}: expected {}, got {}",
+ path,
+ entry.checksum,
+ file.hash
+ );
+ }
+ } else {
+ bail!("File {} not found in the tarball", path);
+ }
+ }
+
+ for (specifier, expected) in &manifest.exports {
+ let actual = package.exports.get(specifier).ok_or_else(|| {
+ deno_core::anyhow::anyhow!(
+ "Export {} not found in the package",
+ specifier
+ )
+ })?;
+ if actual != expected {
+ bail!(
+ "Export {} mismatch: expected {}, got {}",
+ specifier,
+ expected,
+ actual
+ );
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::tar::PublishableTarball;
+ use super::tar::PublishableTarballFile;
+ use super::verify_version_manifest;
+ use std::collections::HashMap;
+
+ #[test]
+ fn test_verify_version_manifest() {
+ let meta = r#"{
+ "manifest": {
+ "mod.ts": {
+ "checksum": "abc123"
+ }
+ },
+ "exports": {}
+ }"#;
+
+ let meta_bytes = meta.as_bytes();
+ let package = super::PreparedPublishPackage {
+ scope: "test".to_string(),
+ package: "test".to_string(),
+ version: "1.0.0".to_string(),
+ tarball: PublishableTarball {
+ bytes: vec![].into(),
+ hash: "abc123".to_string(),
+ files: vec![PublishableTarballFile {
+ specifier: "file://mod.ts".try_into().unwrap(),
+ path_str: "mod.ts".to_string(),
+ hash: "abc123".to_string(),
+ size: 0,
+ }],
+ },
+ config: "deno.json".to_string(),
+ exports: HashMap::new(),
+ };
+
+ assert!(verify_version_manifest(meta_bytes, &package).is_ok());
+ }
+
+ #[test]
+ fn test_verify_version_manifest_missing() {
+ let meta = r#"{
+ "manifest": {
+ "mod.ts": {},
+ },
+ "exports": {}
+ }"#;
+
+ let meta_bytes = meta.as_bytes();
+ let package = super::PreparedPublishPackage {
+ scope: "test".to_string(),
+ package: "test".to_string(),
+ version: "1.0.0".to_string(),
+ tarball: PublishableTarball {
+ bytes: vec![].into(),
+ hash: "abc123".to_string(),
+ files: vec![PublishableTarballFile {
+ specifier: "file://mod.ts".try_into().unwrap(),
+ path_str: "mod.ts".to_string(),
+ hash: "abc123".to_string(),
+ size: 0,
+ }],
+ },
+ config: "deno.json".to_string(),
+ exports: HashMap::new(),
+ };
+
+ assert!(verify_version_manifest(meta_bytes, &package).is_err());
+ }
+
+ #[test]
+ fn test_verify_version_manifest_invalid_hash() {
+ let meta = r#"{
+ "manifest": {
+ "mod.ts": {
+ "checksum": "lol123"
+ },
+ "exports": {}
+ }
+ }"#;
+
+ let meta_bytes = meta.as_bytes();
+ let package = super::PreparedPublishPackage {
+ scope: "test".to_string(),
+ package: "test".to_string(),
+ version: "1.0.0".to_string(),
+ tarball: PublishableTarball {
+ bytes: vec![].into(),
+ hash: "abc123".to_string(),
+ files: vec![PublishableTarballFile {
+ specifier: "file://mod.ts".try_into().unwrap(),
+ path_str: "mod.ts".to_string(),
+ hash: "abc123".to_string(),
+ size: 0,
+ }],
+ },
+ config: "deno.json".to_string(),
+ exports: HashMap::new(),
+ };
+
+ assert!(verify_version_manifest(meta_bytes, &package).is_err());
+ }
}