summaryrefslogtreecommitdiff
path: root/cli/tools/registry/provenance.rs
diff options
context:
space:
mode:
Diffstat (limited to 'cli/tools/registry/provenance.rs')
-rw-r--r--cli/tools/registry/provenance.rs725
1 files changed, 725 insertions, 0 deletions
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");
+ }
+}