diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index fc2a84335..6a1f1b07e 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -84,6 +84,7 @@ Tools for smart contract developers * `extend` — Extend the time to live ledger of a contract-data ledger entry * `deploy` — Deploy a wasm contract * `fetch` — Fetch a contract's Wasm binary +* `verify` — Verify the source that build the Wasm binary * `id` — Generate the contract id for a given contract or asset * `info` — Access info about contracts * `init` — Initialize a Soroban contract project @@ -450,6 +451,26 @@ Fetch a contract's Wasm binary +## `stellar contract verify` + +Verify the source that build the Wasm binary + +**Usage:** `stellar contract verify [OPTIONS] <--wasm |--wasm-hash |--id >` + +###### **Options:** + +* `--wasm ` — Wasm file to extract the data from +* `--wasm-hash ` — Wasm hash to get the data for +* `--id ` — Contract id or contract alias to get the data for +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `--network ` — Name of network to use from config +* `--global` — Use global config +* `--config-dir ` — Location of config directory, default is "." + + + ## `stellar contract id` Generate the contract id for a given contract or asset diff --git a/cmd/soroban-cli/src/commands/contract/info.rs b/cmd/soroban-cli/src/commands/contract/info.rs index 5ca03ab2c..4ca582a3e 100644 --- a/cmd/soroban-cli/src/commands/contract/info.rs +++ b/cmd/soroban-cli/src/commands/contract/info.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; pub mod env_meta; pub mod interface; pub mod meta; -mod shared; +pub mod shared; #[derive(Debug, clap::Subcommand)] pub enum Cmd { diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index d72ce62b6..5bd151440 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -15,6 +15,7 @@ pub mod invoke; pub mod optimize; pub mod read; pub mod restore; +pub mod verify; use crate::commands::global; @@ -45,6 +46,9 @@ pub enum Cmd { /// Fetch a contract's Wasm binary Fetch(fetch::Cmd), + /// Verify the source that build the Wasm binary + Verify(verify::Cmd), + /// Generate the contract id for a given contract or asset #[command(subcommand)] Id(id::Cmd), @@ -113,6 +117,9 @@ pub enum Error { #[error(transparent)] Fetch(#[from] fetch::Error), + #[error(transparent)] + Verify(#[from] verify::Error), + #[error(transparent)] Init(#[from] init::Error), @@ -158,6 +165,7 @@ impl Cmd { Cmd::Invoke(invoke) => invoke.run(global_args).await?, Cmd::Optimize(optimize) => optimize.run()?, Cmd::Fetch(fetch) => fetch.run().await?, + Cmd::Verify(verify) => verify.run(global_args).await?, Cmd::Read(read) => read.run().await?, Cmd::Restore(restore) => restore.run().await?, } diff --git a/cmd/soroban-cli/src/commands/contract/verify.rs b/cmd/soroban-cli/src/commands/contract/verify.rs new file mode 100644 index 000000000..02769dd7b --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/verify.rs @@ -0,0 +1,250 @@ +use super::info::shared; +use crate::{ + commands::{contract::info::shared::fetch_wasm, global}, + print::Print, + utils::http, +}; +use base64::Engine as _; +use clap::{command, Parser}; +use sha2::{Digest, Sha256}; +use soroban_spec_tools::contract; +use soroban_spec_tools::contract::Spec; +use std::fmt::Debug; +use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub common: shared::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Wasm(#[from] shared::Error), + #[error(transparent)] + Spec(#[from] contract::Error), + #[error("'source_repo' meta entry is not stored in the contract")] + SourceRepoNotSpecified, + #[error("'source_repo' meta entry '{0}' has prefix unsupported, only 'github:' supported")] + SourceRepoUnsupported(String), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("GitHub attestation not found")] + AttestationNotFound, + #[error("GitHub attestation invalid")] + AttestationInvalid, +} + +impl Cmd { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(global_args.quiet); + + print.infoln("Loading wasm..."); + let Some(bytes) = fetch_wasm(&self.common).await? else { + return Err(Error::SourceRepoNotSpecified); + }; + let wasm_hash = Sha256::digest(&bytes); + let wasm_hash_hex = hex::encode(wasm_hash); + print.infoln(format!("Wasm Hash: {wasm_hash_hex}")); + + let spec = Spec::new(&bytes)?; + let Some(source_repo) = spec.meta.iter().find_map(|meta_entry| { + let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = meta_entry; + if key.to_string() == "source_repo" { + Some(val.to_string()) + } else { + None + } + }) else { + return Err(Error::SourceRepoNotSpecified); + }; + print.infoln(format!("Source Repo: {source_repo}")); + let Some(github_source_repo) = source_repo.strip_prefix("github:") else { + return Err(Error::SourceRepoUnsupported(source_repo)); + }; + + let url = format!( + "https://api.github.com/repos/{github_source_repo}/attestations/sha256:{wasm_hash_hex}" + ); + print.infoln(format!("Collecting GitHub attestation from {url}...")); + let resp = http::client().get(url).send().await?; + let resp: gh_attest_resp::Root = resp.json().await?; + let Some(attestation) = resp.attestations.first() else { + return Err(Error::AttestationNotFound); + }; + let Ok(payload) = base64::engine::general_purpose::STANDARD + .decode(&attestation.bundle.dsse_envelope.payload) + else { + return Err(Error::AttestationInvalid); + }; + let payload: gh_payload::Root = serde_json::from_slice(&payload)?; + print.checkln("Attestation found linked to GitHub Actions Workflow Run:"); + let workflow_repo = payload + .predicate + .build_definition + .external_parameters + .workflow + .repository; + let workflow_ref = payload + .predicate + .build_definition + .external_parameters + .workflow + .ref_field; + let workflow_path = payload + .predicate + .build_definition + .external_parameters + .workflow + .path; + let git_commit = &payload + .predicate + .build_definition + .resolved_dependencies + .first() + .unwrap() + .digest + .git_commit; + let runner_environment = payload + .predicate + .build_definition + .internal_parameters + .github + .runner_environment + .as_str(); + print.checkln(format!(" • Repository: {workflow_repo}")); + print.checkln(format!(" • Ref: {workflow_ref}")); + print.checkln(format!(" • Path: {workflow_path}")); + print.checkln(format!(" • Git Commit: {git_commit}")); + match runner_environment + { + runner @ "github-hosted" => print.checkln(format!(" • Runner: {runner}")), + runner => print.warnln(format!(" • Runner: {runner} (runners not hosted by GitHub could have any configuration or environmental changes)")), + } + print.checkln(format!( + " • Run: {}", + payload.predicate.run_details.metadata.invocation_id + )); + print.globeln(format!( + "View the workflow at {workflow_repo}/blob/{git_commit}/{workflow_path}" + )); + print.globeln(format!( + "View the repo at {workflow_repo}/tree/{git_commit}" + )); + + Ok(()) + } +} + +mod gh_attest_resp { + use serde::Deserialize; + use serde::Serialize; + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Root { + pub attestations: Vec, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Attestation { + pub bundle: Bundle, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Bundle { + pub dsse_envelope: DsseEnvelope, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct DsseEnvelope { + pub payload: String, + } +} + +mod gh_payload { + use serde::Deserialize; + use serde::Serialize; + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Root { + pub predicate_type: String, + pub predicate: Predicate, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Predicate { + pub build_definition: BuildDefinition, + pub run_details: RunDetails, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct BuildDefinition { + pub external_parameters: ExternalParameters, + pub internal_parameters: InternalParameters, + pub resolved_dependencies: Vec, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExternalParameters { + pub workflow: Workflow, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Workflow { + #[serde(rename = "ref")] + pub ref_field: String, + pub repository: String, + pub path: String, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct InternalParameters { + pub github: Github, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Github { + #[serde(rename = "runner_environment")] + pub runner_environment: String, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ResolvedDependency { + pub uri: String, + pub digest: Digest, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Digest { + pub git_commit: String, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct RunDetails { + pub metadata: Metadata, + } + + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Metadata { + pub invocation_id: String, + } +}