summaryrefslogtreecommitdiff
path: root/cli/tools
diff options
context:
space:
mode:
Diffstat (limited to 'cli/tools')
-rw-r--r--cli/tools/registry/auth.rs8
-rw-r--r--cli/tools/registry/mod.rs247
-rw-r--r--cli/tools/registry/provenance.rs725
-rw-r--r--cli/tools/registry/tar.rs5
4 files changed, 981 insertions, 4 deletions
diff --git a/cli/tools/registry/auth.rs b/cli/tools/registry/auth.rs
index 52936bc98..3d40fa766 100644
--- a/cli/tools/registry/auth.rs
+++ b/cli/tools/registry/auth.rs
@@ -17,6 +17,14 @@ pub struct OidcConfig {
pub token: String,
}
+pub(crate) fn is_gha() -> bool {
+ std::env::var("GITHUB_ACTIONS").unwrap_or_default() == "true"
+}
+
+pub(crate) fn gha_oidc_token() -> Option<String> {
+ std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").ok()
+}
+
fn get_gh_oidc_env_vars() -> Option<Result<(String, String), AnyError>> {
if std::env::var("GITHUB_ACTIONS").unwrap_or_default() == "true" {
let url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL");
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());
+ }
}
diff --git a/cli/tools/registry/provenance.rs b/cli/tools/registry/provenance.rs
new file mode 100644
index 000000000..117c10abc
--- /dev/null
+++ b/cli/tools/registry/provenance.rs
@@ -0,0 +1,725 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+use super::api::OidcTokenResponse;
+use super::auth::gha_oidc_token;
+use super::auth::is_gha;
+use base64::engine::general_purpose::STANDARD_NO_PAD;
+use base64::prelude::BASE64_STANDARD;
+use base64::Engine as _;
+use deno_core::anyhow;
+use deno_core::anyhow::bail;
+use deno_core::error::AnyError;
+use deno_core::serde_json;
+use once_cell::sync::Lazy;
+use p256::elliptic_curve;
+use p256::pkcs8::AssociatedOid;
+use reqwest::Client;
+use ring::rand::SystemRandom;
+use ring::signature::EcdsaKeyPair;
+use ring::signature::KeyPair;
+use serde::Deserialize;
+use serde::Serialize;
+use sha2::Digest;
+use spki::der::asn1;
+use spki::der::pem::LineEnding;
+use spki::der::EncodePem;
+use std::collections::HashMap;
+use std::env;
+
+const PAE_PREFIX: &str = "DSSEv1";
+
+/// DSSE Pre-Auth Encoding
+///
+/// https://github.com/secure-systems-lab/dsse/blob/master/protocol.md#signature-definition
+fn pre_auth_encoding(payload_type: &str, payload: &str) -> Vec<u8> {
+ format!(
+ "{} {} {} {} {}",
+ PAE_PREFIX,
+ payload_type.len(),
+ payload_type,
+ payload.len(),
+ payload,
+ )
+ .into_bytes()
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Signature {
+ keyid: &'static str,
+ sig: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Envelope {
+ payload_type: String,
+ payload: String,
+ signatures: Vec<Signature>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SignatureBundle {
+ #[serde(rename = "$case")]
+ case: &'static str,
+ dsse_envelope: Envelope,
+}
+
+#[derive(Serialize)]
+pub struct SubjectDigest {
+ pub sha256: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Subject {
+ pub name: String,
+ pub digest: SubjectDigest,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GhaResourceDigest {
+ git_commit: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GithubInternalParameters {
+ event_name: String,
+ repository_id: String,
+ repository_owner_id: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ResourceDescriptor {
+ uri: String,
+ digest: Option<GhaResourceDigest>,
+}
+
+#[derive(Serialize)]
+struct InternalParameters {
+ github: GithubInternalParameters,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GhaWorkflow {
+ #[serde(rename = "ref")]
+ ref_: String,
+ repository: String,
+ path: String,
+}
+
+#[derive(Serialize)]
+struct ExternalParameters {
+ workflow: GhaWorkflow,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct BuildDefinition {
+ build_type: &'static str,
+ resolved_dependencies: [ResourceDescriptor; 1],
+ internal_parameters: InternalParameters,
+ external_parameters: ExternalParameters,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Builder {
+ id: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Metadata {
+ invocation_id: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RunDetails {
+ builder: Builder,
+ metadata: Metadata,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Predicate {
+ build_definition: BuildDefinition,
+ run_details: RunDetails,
+}
+
+impl Predicate {
+ pub fn new_github_actions() -> Self {
+ let repo =
+ std::env::var("GITHUB_REPOSITORY").expect("GITHUB_REPOSITORY not set");
+ let rel_ref = std::env::var("GITHUB_WORKFLOW_REF")
+ .unwrap_or_default()
+ .replace(&format!("{}/", &repo), "");
+
+ let delimn = rel_ref.find('@').unwrap();
+ let (workflow_path, mut workflow_ref) = rel_ref.split_at(delimn);
+ workflow_ref = &workflow_ref[1..];
+
+ let server_url = std::env::var("GITHUB_SERVER_URL").unwrap();
+
+ Self {
+ build_definition: BuildDefinition {
+ build_type: GITHUB_BUILD_TYPE,
+ external_parameters: ExternalParameters {
+ workflow: GhaWorkflow {
+ ref_: workflow_ref.to_string(),
+ repository: format!("{}/{}", server_url, &repo),
+ path: workflow_path.to_string(),
+ },
+ },
+ internal_parameters: InternalParameters {
+ github: GithubInternalParameters {
+ event_name: std::env::var("GITHUB_EVENT_NAME").unwrap_or_default(),
+ repository_id: std::env::var("GITHUB_REPOSITORY_ID")
+ .unwrap_or_default(),
+ repository_owner_id: std::env::var("GITHUB_REPOSITORY_OWNER_ID")
+ .unwrap_or_default(),
+ },
+ },
+ resolved_dependencies: [ResourceDescriptor {
+ uri: format!(
+ "git+{}/{}@{}",
+ server_url,
+ &repo,
+ std::env::var("GITHUB_REF").unwrap()
+ ),
+ digest: Some(GhaResourceDigest {
+ git_commit: std::env::var("GITHUB_SHA").unwrap(),
+ }),
+ }],
+ },
+ run_details: RunDetails {
+ builder: Builder {
+ id: format!(
+ "{}/{}",
+ &GITHUB_BUILDER_ID_PREFIX,
+ std::env::var("RUNNER_ENVIRONMENT").unwrap()
+ ),
+ },
+ metadata: Metadata {
+ invocation_id: format!(
+ "{}/{}/actions/runs/{}/attempts/{}",
+ server_url,
+ repo,
+ std::env::var("GITHUB_RUN_ID").unwrap(),
+ std::env::var("GITHUB_RUN_ATTEMPT").unwrap()
+ ),
+ },
+ },
+ }
+ }
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProvenanceAttestation {
+ #[serde(rename = "type")]
+ _type: &'static str,
+ subject: Subject,
+ predicate_type: &'static str,
+ predicate: Predicate,
+}
+
+impl ProvenanceAttestation {
+ pub fn new_github_actions(subject: Subject) -> Self {
+ Self {
+ _type: INTOTO_STATEMENT_TYPE,
+ subject,
+ predicate_type: SLSA_PREDICATE_TYPE,
+ predicate: Predicate::new_github_actions(),
+ }
+ }
+}
+
+const INTOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
+const SLSA_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
+const INTOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
+
+const GITHUB_BUILDER_ID_PREFIX: &str = "https://github.com/actions/runner";
+const GITHUB_BUILD_TYPE: &str =
+ "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct X509Certificate {
+ pub raw_bytes: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct X509CertificateChain {
+ pub certificates: [X509Certificate; 1],
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerificationMaterialContent {
+ #[serde(rename = "$case")]
+ pub case: &'static str,
+ pub x509_certificate_chain: X509CertificateChain,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TlogEntry {
+ pub log_index: u64,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerificationMaterial {
+ pub content: VerificationMaterialContent,
+ pub tlog_entries: [TlogEntry; 1],
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProvenanceBundle {
+ pub media_type: &'static str,
+ pub content: SignatureBundle,
+ pub verification_material: VerificationMaterial,
+}
+
+pub async fn generate_provenance(
+ subject: Subject,
+) -> Result<ProvenanceBundle, AnyError> {
+ if !is_gha() {
+ bail!("Automatic provenance is only available in GitHub Actions");
+ }
+
+ if gha_oidc_token().is_none() {
+ bail!(
+ "Provenance generation in Github Actions requires 'id-token' permission"
+ );
+ };
+
+ let slsa = ProvenanceAttestation::new_github_actions(subject);
+
+ let attestation = serde_json::to_string(&slsa)?;
+ let bundle = attest(&attestation, INTOTO_PAYLOAD_TYPE).await?;
+
+ Ok(bundle)
+}
+
+pub async fn attest(
+ data: &str,
+ type_: &str,
+) -> Result<ProvenanceBundle, AnyError> {
+ // DSSE Pre-Auth Encoding (PAE) payload
+ let pae = pre_auth_encoding(type_, data);
+
+ let signer = FulcioSigner::new()?;
+ let (signature, key_material) = signer.sign(&pae).await?;
+
+ let content = SignatureBundle {
+ case: "dsseSignature",
+ dsse_envelope: Envelope {
+ payload_type: type_.to_string(),
+ payload: BASE64_STANDARD.encode(data),
+ signatures: vec![Signature {
+ keyid: "",
+ sig: BASE64_STANDARD.encode(signature.as_ref()),
+ }],
+ },
+ };
+ let transparency_logs = testify(&content, &key_material.certificate).await?;
+
+ // First log entry is the one we're interested in
+ let (_, log_entry) = transparency_logs.iter().next().unwrap();
+
+ let bundle = ProvenanceBundle {
+ media_type: "application/vnd.in-toto+json",
+ content,
+ verification_material: VerificationMaterial {
+ content: VerificationMaterialContent {
+ case: "x509CertificateChain",
+ x509_certificate_chain: X509CertificateChain {
+ certificates: [X509Certificate {
+ raw_bytes: key_material.certificate,
+ }],
+ },
+ },
+ tlog_entries: [TlogEntry {
+ log_index: log_entry.log_index,
+ }],
+ },
+ };
+
+ Ok(bundle)
+}
+
+static DEFAULT_FULCIO_URL: Lazy<String> = Lazy::new(|| {
+ env::var("FULCIO_URL")
+ .unwrap_or_else(|_| "https://fulcio.sigstore.dev".to_string())
+});
+
+struct FulcioSigner {
+ // The ephemeral key pair used to sign.
+ ephemeral_signer: EcdsaKeyPair,
+ rng: SystemRandom,
+ client: Client,
+}
+
+static ALGORITHM: &ring::signature::EcdsaSigningAlgorithm =
+ &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING;
+
+struct KeyMaterial {
+ pub _case: &'static str,
+ pub certificate: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct PublicKey {
+ algorithm: &'static str,
+ content: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct PublicKeyRequest {
+ public_key: PublicKey,
+ proof_of_possession: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Credentials {
+ oidc_identity_token: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CreateSigningCertificateRequest {
+ credentials: Credentials,
+ public_key_request: PublicKeyRequest,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CertificateChain {
+ certificates: Vec<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct SignedCertificate {
+ chain: CertificateChain,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct SigningCertificateResponse {
+ signed_certificate_embedded_sct: Option<SignedCertificate>,
+ signed_certificate_detached_sct: Option<SignedCertificate>,
+}
+
+impl FulcioSigner {
+ pub fn new() -> Result<Self, AnyError> {
+ let rng = SystemRandom::new();
+ let document = EcdsaKeyPair::generate_pkcs8(ALGORITHM, &rng)?;
+ let ephemeral_signer =
+ EcdsaKeyPair::from_pkcs8(ALGORITHM, document.as_ref(), &rng)?;
+
+ Ok(Self {
+ ephemeral_signer,
+ rng,
+ client: Client::new(),
+ })
+ }
+
+ pub async fn sign(
+ self,
+ data: &[u8],
+ ) -> Result<(ring::signature::Signature, KeyMaterial), AnyError> {
+ // Request token from GitHub Actions for audience "sigstore"
+ let token = gha_request_token("sigstore").await?;
+ // Extract the subject from the token
+ let subject = extract_jwt_subject(&token)?;
+
+ // Sign the subject to create a challenge
+ let challenge =
+ self.ephemeral_signer.sign(&self.rng, subject.as_bytes())?;
+
+ let subject_public_key = self.ephemeral_signer.public_key().as_ref();
+ let algorithm = spki::AlgorithmIdentifier {
+ oid: elliptic_curve::ALGORITHM_OID,
+ parameters: Some((&p256::NistP256::OID).into()),
+ };
+ let spki = spki::SubjectPublicKeyInfoRef {
+ algorithm,
+ subject_public_key: asn1::BitStringRef::from_bytes(subject_public_key)?,
+ };
+ let pem = spki.to_pem(LineEnding::LF)?;
+
+ // Create signing certificate
+ let certificates = self
+ .create_signing_certificate(&token, pem, challenge)
+ .await?;
+
+ let signature = self.ephemeral_signer.sign(&self.rng, data)?;
+
+ Ok((
+ signature,
+ KeyMaterial {
+ _case: "x509Certificate",
+ certificate: certificates[0].clone(),
+ },
+ ))
+ }
+
+ async fn create_signing_certificate(
+ &self,
+ token: &str,
+ public_key: String,
+ challenge: ring::signature::Signature,
+ ) -> Result<Vec<String>, AnyError> {
+ let url = format!("{}/api/v2/signingCert", *DEFAULT_FULCIO_URL);
+ let request_body = CreateSigningCertificateRequest {
+ credentials: Credentials {
+ oidc_identity_token: token.to_string(),
+ },
+ public_key_request: PublicKeyRequest {
+ public_key: PublicKey {
+ algorithm: "ECDSA",
+ content: public_key,
+ },
+ proof_of_possession: BASE64_STANDARD.encode(challenge.as_ref()),
+ },
+ };
+
+ let response = self.client.post(url).json(&request_body).send().await?;
+
+ let body: SigningCertificateResponse = response.json().await?;
+
+ let key = body
+ .signed_certificate_embedded_sct
+ .or(body.signed_certificate_detached_sct)
+ .ok_or_else(|| anyhow::anyhow!("No certificate chain returned"))?;
+ Ok(key.chain.certificates)
+ }
+}
+
+#[derive(Deserialize)]
+struct JwtSubject<'a> {
+ email: Option<String>,
+ sub: String,
+ iss: &'a str,
+}
+
+fn extract_jwt_subject(token: &str) -> Result<String, AnyError> {
+ let parts: Vec<&str> = token.split('.').collect();
+
+ let payload = parts[1];
+ let payload = STANDARD_NO_PAD.decode(payload)?;
+
+ let subject: JwtSubject = serde_json::from_slice(&payload)?;
+ match subject.iss {
+ "https://accounts.google.com" | "https://oauth2.sigstore.dev/auth" => {
+ Ok(subject.email.unwrap_or(subject.sub))
+ }
+ _ => Ok(subject.sub),
+ }
+}
+
+async fn gha_request_token(aud: &str) -> Result<String, AnyError> {
+ let Ok(req_url) = env::var("ACTIONS_ID_TOKEN_REQUEST_URL") else {
+ bail!("Not running in GitHub Actions");
+ };
+
+ let Some(token) = gha_oidc_token() else {
+ bail!("No OIDC token available");
+ };
+
+ let client = Client::new();
+ let res = client
+ .get(&req_url)
+ .bearer_auth(token)
+ .query(&[("audience", aud)])
+ .send()
+ .await?
+ .json::<OidcTokenResponse>()
+ .await?;
+ Ok(res.value)
+}
+
+static DEFAULT_REKOR_URL: Lazy<String> = Lazy::new(|| {
+ env::var("REKOR_URL")
+ .unwrap_or_else(|_| "https://rekor.sigstore.dev".to_string())
+});
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LogEntry {
+ #[serde(rename = "logID")]
+ pub log_id: String,
+ pub log_index: u64,
+}
+
+type RekorEntry = HashMap<String, LogEntry>;
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RekorSignature {
+ sig: String,
+ // `publicKey` is not the standard part of
+ // DSSE, but it's required by Rekor.
+ public_key: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DsseEnvelope {
+ payload: String,
+ payload_type: String,
+ signatures: [RekorSignature; 1],
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProposedIntotoEntry {
+ api_version: &'static str,
+ kind: &'static str,
+ spec: ProposedIntotoEntrySpec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProposedIntotoEntrySpec {
+ content: ProposedIntotoEntryContent,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProposedIntotoEntryContent {
+ envelope: DsseEnvelope,
+ hash: ProposedIntotoEntryHash,
+ payload_hash: ProposedIntotoEntryHash,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProposedIntotoEntryHash {
+ algorithm: &'static str,
+ value: String,
+}
+
+// Rekor witness
+async fn testify(
+ content: &SignatureBundle,
+ public_key: &str,
+) -> Result<RekorEntry, AnyError> {
+ // Rekor "intoto" entry for the given DSSE envelope and signature.
+ //
+ // Calculate the value for the payloadHash field into the Rekor entry
+ let payload_hash = hex::encode(sha2::Sha256::digest(
+ content.dsse_envelope.payload.as_bytes(),
+ ));
+
+ // Calculate the value for the hash field into the Rekor entry
+ let envelope_hash = hex::encode({
+ let dsse = DsseEnvelope {
+ payload: content.dsse_envelope.payload.clone(),
+ payload_type: content.dsse_envelope.payload_type.clone(),
+ signatures: [RekorSignature {
+ sig: content.dsse_envelope.signatures[0].sig.clone(),
+ public_key: public_key.to_string(),
+ }],
+ };
+
+ sha2::Sha256::digest(serde_json::to_string(&dsse)?.as_bytes())
+ });
+
+ // Re-create the DSSE envelop. `publicKey` is not the standard part of
+ // DSSE, but it's required by Rekor.
+ //
+ // Double-encode payload and signature cause that's what Rekor expects
+ let dsse = DsseEnvelope {
+ payload_type: content.dsse_envelope.payload_type.clone(),
+ payload: BASE64_STANDARD.encode(content.dsse_envelope.payload.clone()),
+ signatures: [RekorSignature {
+ sig: BASE64_STANDARD
+ .encode(content.dsse_envelope.signatures[0].sig.clone()),
+ public_key: BASE64_STANDARD.encode(public_key),
+ }],
+ };
+
+ let proposed_intoto_entry = ProposedIntotoEntry {
+ api_version: "0.0.2",
+ kind: "intoto",
+ spec: ProposedIntotoEntrySpec {
+ content: ProposedIntotoEntryContent {
+ envelope: dsse,
+ hash: ProposedIntotoEntryHash {
+ algorithm: "sha256",
+ value: envelope_hash,
+ },
+ payload_hash: ProposedIntotoEntryHash {
+ algorithm: "sha256",
+ value: payload_hash,
+ },
+ },
+ },
+ };
+
+ let client = Client::new();
+ let url = format!("{}/api/v1/log/entries", *DEFAULT_REKOR_URL);
+ let res = client
+ .post(&url)
+ .json(&proposed_intoto_entry)
+ .send()
+ .await?;
+ let body: RekorEntry = res.json().await?;
+
+ Ok(body)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::ProvenanceAttestation;
+ use super::Subject;
+ use super::SubjectDigest;
+ use std::env;
+
+ #[test]
+ fn slsa_github_actions() {
+ // Set environment variable
+ if env::var("GITHUB_ACTIONS").is_err() {
+ env::set_var("CI", "true");
+ env::set_var("GITHUB_ACTIONS", "true");
+ env::set_var("ACTIONS_ID_TOKEN_REQUEST_URL", "https://example.com");
+ env::set_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "dummy");
+ env::set_var("GITHUB_REPOSITORY", "littledivy/deno_sdl2");
+ env::set_var("GITHUB_SERVER_URL", "https://github.com");
+ env::set_var("GITHUB_REF", "refs/tags/sdl2@0.0.1");
+ env::set_var("GITHUB_SHA", "lol");
+ env::set_var("GITHUB_RUN_ID", "1");
+ env::set_var("GITHUB_RUN_ATTEMPT", "1");
+ env::set_var("RUNNER_ENVIRONMENT", "github-hosted");
+ env::set_var(
+ "GITHUB_WORKFLOW_REF",
+ "littledivy/deno_sdl2@refs/tags/sdl2@0.0.1",
+ );
+ }
+
+ let subject = Subject {
+ name: "jsr:@divy/sdl2@0.0.1".to_string(),
+ digest: SubjectDigest {
+ sha256: "yourmom".to_string(),
+ },
+ };
+ let slsa = ProvenanceAttestation::new_github_actions(subject);
+ assert_eq!(slsa.subject.name, "jsr:@divy/sdl2@0.0.1");
+ assert_eq!(slsa.subject.digest.sha256, "yourmom");
+ }
+}
diff --git a/cli/tools/registry/tar.rs b/cli/tools/registry/tar.rs
index a8519fe02..2dd160a0a 100644
--- a/cli/tools/registry/tar.rs
+++ b/cli/tools/registry/tar.rs
@@ -25,7 +25,9 @@ use super::unfurl::SpecifierUnfurler;
#[derive(Debug, Clone, PartialEq)]
pub struct PublishableTarballFile {
+ pub path_str: String,
pub specifier: Url,
+ pub hash: String,
pub size: usize,
}
@@ -153,7 +155,10 @@ pub fn create_gzipped_tarball(
diagnostics_collector,
)?;
files.push(PublishableTarballFile {
+ path_str: path_str.clone(),
specifier: specifier.clone(),
+ // This hash string matches the checksum computed by registry
+ hash: format!("sha256-{:x}", sha2::Sha256::digest(&content)),
size: content.len(),
});
tar