diff options
Diffstat (limited to 'cli/tools/registry/provenance.rs')
-rw-r--r-- | cli/tools/registry/provenance.rs | 725 |
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"); + } +} |