From 1ff01aa4247ba90c3d03d6af98989c6679d53340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:04:03 +0100 Subject: [PATCH 1/6] fix(plugins): harden install preflight and signature verification --- .github/workflows/publish-plugins.yml | 39 ++- clients/agent-runtime/src/plugins/mod.rs | 173 +++++++++++- clients/web/apps/marketing/public/install | 321 +++++++++++++++++++++- 3 files changed, 520 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 9c9f2744f..2b3fb5cf0 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -497,19 +497,38 @@ jobs: env: DIST_DIR: ${{ env.DIST_DIR }} ARTIFACT_RELATIVE_PATH: ${{ steps.meta.outputs.artifact_relative_path }} + shell: python run: | - set -euo pipefail + import os + import pathlib + import subprocess - artifact_path="$DIST_DIR/$ARTIFACT_RELATIVE_PATH" - signature_path="${artifact_path}.sig" - certificate_path="${artifact_path}.pem" + artifact_path = pathlib.Path(os.environ["DIST_DIR"]) / os.environ["ARTIFACT_RELATIVE_PATH"] + signature_path = artifact_path.with_suffix(artifact_path.suffix + ".sig") + certificate_path = artifact_path.with_suffix(artifact_path.suffix + ".pem") - ./cosign verify-blob \ - --certificate "$certificate_path" \ - --signature "$signature_path" \ - --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - --certificate-identity-regexp "https://github.com/${GITHUB_REPOSITORY}/\\.github/workflows/publish-plugins.yml@.*" \ - "$artifact_path" + certificate = certificate_path.read_text(encoding="utf-8") + if "-----BEGIN CERTIFICATE-----" not in certificate or "-----END CERTIFICATE-----" not in certificate: + raise SystemExit( + "cosign certificate output is not PEM text; refusing to publish wrapped certificate payload", + ) + + subprocess.run( + [ + "./cosign", + "verify-blob", + "--certificate", + str(certificate_path), + "--signature", + str(signature_path), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity-regexp", + f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/\\.github/workflows/publish-plugins.yml@.*", + str(artifact_path), + ], + check=True, + ) - name: 🚀 Optional publish plugin artifact to OCI if: ${{ steps.meta.outputs.oci_repository != '' }} diff --git a/clients/agent-runtime/src/plugins/mod.rs b/clients/agent-runtime/src/plugins/mod.rs index 4ca7a547a..627828f16 100644 --- a/clients/agent-runtime/src/plugins/mod.rs +++ b/clients/agent-runtime/src/plugins/mod.rs @@ -1,5 +1,6 @@ use crate::config::{Config, PluginSourceConfig, PluginsConfig}; use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; use chrono::Utc; use reqwest::Url; use serde::{Deserialize, Serialize}; @@ -972,6 +973,30 @@ impl PluginManager { ); } + let (signature, certificate) = normalize_signature_bundle(&signature, &certificate) + .with_context(|| { + format!( + "Plugin '{}' signature bundle normalization failed", + candidate.manifest.id + ) + })?; + + if signature.is_empty() || signature.len() > MAX_SIGNATURE_BYTES { + bail!( + "Plugin '{}' signature has invalid size after normalization ({} bytes)", + candidate.manifest.id, + signature.len() + ); + } + + if certificate.is_empty() || certificate.len() > MAX_CERTIFICATE_BYTES { + bail!( + "Plugin '{}' signing certificate has invalid size after normalization ({} bytes)", + candidate.manifest.id, + certificate.len() + ); + } + verify_blob_with_cosign( artifact_bytes, &signature, @@ -1596,6 +1621,10 @@ fn verify_blob_with_cosign( certificate: &[u8], identity_regex: &str, ) -> Result<()> { + let (normalized_signature, normalized_certificate) = + normalize_signature_bundle(signature, certificate) + .context("Failed to normalize plugin signature bundle")?; + let temp_dir = std::env::temp_dir().join(format!("corvus-plugin-verify-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).with_context(|| { @@ -1611,8 +1640,8 @@ fn verify_blob_with_cosign( let verify_result = (|| -> Result<()> { write_private_file(&artifact_path, artifact)?; - write_private_file(&signature_path, signature)?; - write_private_file(&certificate_path, certificate)?; + write_private_file(&signature_path, &normalized_signature)?; + write_private_file(&certificate_path, &normalized_certificate)?; let mut command = Command::new("cosign"); command @@ -1650,6 +1679,93 @@ fn verify_blob_with_cosign( verify_result } +fn normalize_signature_bundle(signature: &[u8], certificate: &[u8]) -> Result<(Vec, Vec)> { + let normalized_signature = normalize_signature_payload(signature); + let normalized_certificate = normalize_certificate_payload(certificate)?; + Ok((normalized_signature, normalized_certificate)) +} + +fn normalize_certificate_payload(certificate: &[u8]) -> Result> { + if contains_pem_certificate_markers(certificate) { + return Ok(certificate.to_vec()); + } + + let as_text = std::str::from_utf8(certificate).context( + "Certificate payload is not valid UTF-8 and does not contain PEM certificate markers", + )?; + let trimmed = as_text.trim(); + if trimmed.is_empty() { + bail!("Certificate payload is empty"); + } + + let decoded = decode_base64_text(trimmed).ok_or_else(|| { + anyhow!("Certificate payload is neither PEM text nor base64-encoded PEM text",) + })?; + + if !contains_pem_certificate_markers(&decoded) { + bail!("Decoded certificate payload is missing PEM certificate markers"); + } + + Ok(decoded) +} + +fn normalize_signature_payload(signature: &[u8]) -> Vec { + let Ok(signature_text) = std::str::from_utf8(signature) else { + return signature.to_vec(); + }; + let trimmed_signature = signature_text.trim(); + if !looks_like_cosign_signature_text(trimmed_signature) { + return signature.to_vec(); + } + + let Some(decoded_outer) = decode_base64_text(trimmed_signature) else { + return signature.to_vec(); + }; + let Ok(decoded_outer_text) = std::str::from_utf8(&decoded_outer) else { + return signature.to_vec(); + }; + + let inner_signature = decoded_outer_text.trim(); + if !looks_like_cosign_signature_text(inner_signature) { + return signature.to_vec(); + } + + inner_signature.as_bytes().to_vec() +} + +fn contains_pem_certificate_markers(payload: &[u8]) -> bool { + let Ok(text) = std::str::from_utf8(payload) else { + return false; + }; + let trimmed = text.trim(); + trimmed.contains("-----BEGIN CERTIFICATE-----") && trimmed.contains("-----END CERTIFICATE-----") +} + +fn looks_like_cosign_signature_text(payload: &str) -> bool { + if payload.is_empty() || payload.lines().count() != 1 { + return false; + } + + payload.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '/' | '=' | '-' | '_') + }) +} + +fn decode_base64_text(payload: &str) -> Option> { + let compact: String = payload + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect(); + if compact.is_empty() { + return None; + } + + general_purpose::STANDARD + .decode(compact.as_bytes()) + .ok() + .or_else(|| general_purpose::URL_SAFE.decode(compact.as_bytes()).ok()) +} + struct CommandOutput { status: std::process::ExitStatus, stdout: Vec, @@ -2383,4 +2499,57 @@ mod tests { .expect_err("install should fail when publisher is not allowlisted"); assert!(error.to_string().contains("allowlisted")); } + + #[test] + fn normalize_certificate_payload_accepts_plain_pem() { + let pem = b"-----BEGIN CERTIFICATE-----\nZm9v\n-----END CERTIFICATE-----\n"; + let normalized = normalize_certificate_payload(pem).expect("plain PEM should be accepted"); + assert_eq!(normalized, pem); + } + + #[test] + fn normalize_certificate_payload_decodes_base64_wrapped_pem() { + let pem = "-----BEGIN CERTIFICATE-----\nZm9v\n-----END CERTIFICATE-----\n"; + let wrapped = general_purpose::STANDARD.encode(pem.as_bytes()); + + let normalized = normalize_certificate_payload(wrapped.as_bytes()) + .expect("base64 wrapped PEM should be normalized"); + assert_eq!(normalized, pem.as_bytes()); + } + + #[test] + fn normalize_certificate_payload_rejects_non_pem_payload() { + let error = normalize_certificate_payload(b"not-a-certificate") + .expect_err("non-PEM certificate payload must be rejected"); + assert!(error + .to_string() + .contains("neither PEM text nor base64-encoded PEM text")); + } + + #[test] + fn normalize_signature_payload_keeps_direct_cosign_signature() { + let signature = b"MEUCIQDxEXAMPLEaBcdEfghIjklMNOPqrsTuvWxyZ0123456789ab==\n"; + let normalized = normalize_signature_payload(signature); + assert_eq!(normalized, signature); + } + + #[test] + fn normalize_signature_payload_decodes_base64_wrapped_signature_text() { + let inner = "MEUCIQDxEXAMPLEaBcdEfghIjklMNOPqrsTuvWxyZ0123456789ab=="; + let wrapped = general_purpose::STANDARD.encode(inner.as_bytes()); + + let normalized = normalize_signature_payload(wrapped.as_bytes()); + assert_eq!(normalized, inner.as_bytes()); + } + + #[test] + fn normalize_signature_bundle_rejects_invalid_certificate_payload() { + let signature = b"MEUCIQDxEXAMPLEaBcdEfghIjklMNOPqrsTuvWxyZ0123456789ab=="; + let error = normalize_signature_bundle(signature, b"totally-invalid-cert") + .expect_err("invalid certificate must fail normalization"); + assert!(error + .to_string() + .to_ascii_lowercase() + .contains("certificate")); + } } diff --git a/clients/web/apps/marketing/public/install b/clients/web/apps/marketing/public/install index 7270dc9f7..34886230f 100755 --- a/clients/web/apps/marketing/public/install +++ b/clients/web/apps/marketing/public/install @@ -21,6 +21,10 @@ AUTO_APPROVE="${CORVUS_YES:-0}" RUN_ONBOARD="${CORVUS_NO_ONBOARD:-0}" SKIP_CHECKSUM="${CORVUS_SKIP_CHECKSUM:-0}" +OS_TYPE="" +LINUX_DISTRO="" +PKG_MGR="" + RESOLVED_DOWNLOAD_URL="" RESOLVED_CHECKSUM_URL="" RESOLVED_ASSET_NAME="" @@ -126,6 +130,320 @@ require_dependencies() { has_cmd curl || die "curl is required. Please install curl and retry." } +detect_platform() { + local uname_s="" + + uname_s="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$uname_s" in + darwin) + OS_TYPE="macos" + LINUX_DISTRO="" + ;; + linux) + OS_TYPE="linux" + if [ -r /etc/os-release ]; then + LINUX_DISTRO="$(awk -F= '/^ID=/{gsub(/\"/, "", $2); print tolower($2)}' /etc/os-release)" + fi + ;; + *) + die "Unsupported OS: ${uname_s}. This installer supports macOS and Linux." + ;; + esac +} + +detect_package_manager() { + PKG_MGR="" + + if [ "$OS_TYPE" = "macos" ]; then + if has_cmd brew; then + PKG_MGR="brew" + return 0 + fi + return 1 + fi + + if [ "$OS_TYPE" = "linux" ]; then + if has_cmd apt-get; then + PKG_MGR="apt-get" + return 0 + fi + if has_cmd dnf; then + PKG_MGR="dnf" + return 0 + fi + if has_cmd yum; then + PKG_MGR="yum" + return 0 + fi + if has_cmd pacman; then + PKG_MGR="pacman" + return 0 + fi + fi + + return 1 +} + +print_preflight_notice() { + print_info "Preflight" + print_warn "Beta disclaimer: Corvus is currently in beta. Behavior and compatibility may change or fail without prior notice." + print_info "This installer will:" + printf " - Install Corvus CLI\n" + printf " - Ensure minimal dependencies for secure install and plugin verification (curl, tar, SHA-256 tooling, and cosign)\n" + printf " - Verify checksums by default before installing binaries\n" +} + +has_checksum_tool() { + has_cmd sha256sum || has_cmd shasum +} + +can_auto_install_dependency() { + local dep="$1" + + case "$PKG_MGR" in + brew) + case "$dep" in + curl|cosign) + return 0 + ;; + tar|sha256) + return 1 + ;; + esac + ;; + apt-get|dnf|yum|pacman) + return 0 + ;; + esac + + return 1 +} + +install_dep_with_pkg_mgr() { + local dep="$1" + + case "$PKG_MGR" in + brew) + case "$dep" in + curl) + brew install curl + ;; + cosign) + brew install cosign + ;; + *) + return 1 + ;; + esac + ;; + apt-get) + case "$dep" in + sha256) + if [ "$(id -u)" -eq 0 ]; then + apt-get update -qq + apt-get install -y -qq coreutils + else + sudo apt-get update -qq + sudo apt-get install -y -qq coreutils + fi + ;; + *) + if [ "$(id -u)" -eq 0 ]; then + apt-get update -qq + apt-get install -y -qq "$dep" + else + sudo apt-get update -qq + sudo apt-get install -y -qq "$dep" + fi + ;; + esac + ;; + dnf) + case "$dep" in + sha256) + if [ "$(id -u)" -eq 0 ]; then + dnf install -y -q coreutils + else + sudo dnf install -y -q coreutils + fi + ;; + *) + if [ "$(id -u)" -eq 0 ]; then + dnf install -y -q "$dep" + else + sudo dnf install -y -q "$dep" + fi + ;; + esac + ;; + yum) + case "$dep" in + sha256) + if [ "$(id -u)" -eq 0 ]; then + yum install -y -q coreutils + else + sudo yum install -y -q coreutils + fi + ;; + *) + if [ "$(id -u)" -eq 0 ]; then + yum install -y -q "$dep" + else + sudo yum install -y -q "$dep" + fi + ;; + esac + ;; + pacman) + case "$dep" in + sha256) + if [ "$(id -u)" -eq 0 ]; then + pacman -Sy --noconfirm coreutils + else + sudo pacman -Sy --noconfirm coreutils + fi + ;; + *) + if [ "$(id -u)" -eq 0 ]; then + pacman -Sy --noconfirm "$dep" + else + sudo pacman -Sy --noconfirm "$dep" + fi + ;; + esac + ;; + *) + return 1 + ;; + esac +} + +print_dependency_manual_help() { + local dep="$1" + + case "$dep" in + curl) + print_error "Could not auto-install curl. Install curl manually and retry." + ;; + tar) + print_error "Could not auto-install tar. Install tar manually and retry." + ;; + sha256) + print_error "Neither sha256sum nor shasum was found. Install coreutils or Perl (shasum) and retry." + ;; + cosign) + print_error "Could not auto-install cosign. Install cosign and retry." + print_info "Reference: https://docs.sigstore.dev/cosign/system_config/installation/" + ;; + esac +} + +ensure_dependency() { + local dep="$1" + local human_name="$2" + local need_install="0" + + case "$dep" in + curl) + has_cmd curl || need_install="1" + ;; + tar) + has_cmd tar || need_install="1" + ;; + sha256) + has_checksum_tool || need_install="1" + ;; + cosign) + has_cmd cosign || need_install="1" + ;; + esac + + if [ "$need_install" = "0" ]; then + return 0 + fi + + print_warn "Missing required dependency: ${human_name}" + + if ! detect_package_manager; then + print_error "No compatible package manager detected for this system." + print_dependency_manual_help "$dep" + return 1 + fi + + if ! can_auto_install_dependency "$dep"; then + print_error "Cannot auto-install ${human_name} with ${PKG_MGR} on this system." + print_dependency_manual_help "$dep" + return 1 + fi + + if [ "$AUTO_APPROVE" != "1" ]; then + if ! is_interactive; then + print_error "Non-interactive mode without --yes and required dependencies are missing." + print_info "Re-run with --yes to auto-install, or install dependencies manually and retry." + return 1 + fi + if ! confirm "Install ${human_name} with ${PKG_MGR}?" "n"; then + print_error "Installation cancelled: ${human_name} is required." + return 1 + fi + fi + + if [ "$OS_TYPE" = "linux" ] && [ "$(id -u)" -ne 0 ] && [ "$PKG_MGR" != "brew" ] && ! has_cmd sudo; then + print_error "sudo is not available and root privileges are required to install ${human_name}." + return 1 + fi + + print_info "Installing ${human_name} with ${PKG_MGR}..." + if ! install_dep_with_pkg_mgr "$dep"; then + print_dependency_manual_help "$dep" + return 1 + fi + + case "$dep" in + curl) + has_cmd curl || return 1 + ;; + tar) + has_cmd tar || return 1 + ;; + sha256) + has_checksum_tool || return 1 + ;; + cosign) + has_cmd cosign || return 1 + ;; + esac + + print_success "Dependency ready: ${human_name}" + return 0 +} + +preflight_dependencies() { + detect_platform + detect_package_manager || true + + if [ "$OS_TYPE" = "macos" ]; then + print_info "Detected system: macOS" + if [ "$PKG_MGR" = "brew" ]; then + print_info "Detected package manager: Homebrew" + else + print_warn "Homebrew not detected. Only compatible dependencies can be auto-installed." + fi + else + print_info "Detected system: Linux${LINUX_DISTRO:+ (${LINUX_DISTRO})}" + if [ -n "$PKG_MGR" ]; then + print_info "Detected package manager: ${PKG_MGR}" + else + print_warn "apt-get, dnf, yum, and pacman were not detected." + fi + fi + + ensure_dependency "curl" "curl" || return 1 + ensure_dependency "tar" "tar" || return 1 + ensure_dependency "sha256" "sha256sum or shasum" || return 1 + ensure_dependency "cosign" "cosign" || return 1 +} + parse_args() { while [ "$#" -gt 0 ]; do case "$1" in @@ -555,11 +873,12 @@ post_install_steps() { main() { require_supported_shell - require_dependencies parse_args "$@" print_info "Corvus installer" print_info "Security note: HTTPS only + checksum verification enabled by default." + print_preflight_notice + preflight_dependencies || die "Preflight failed: required dependencies could not be prepared." select_install_method print_info "Using install method: ${INSTALL_METHOD}" From 13df4b1416be2f5b49d84da0211d258963c22d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:42:19 +0100 Subject: [PATCH 2/6] fix(review): address installer and signature verification findings --- .github/workflows/publish-plugins.yml | 42 ++++++----- clients/agent-runtime/src/plugins/mod.rs | 53 ++++++++++++-- clients/web/apps/marketing/public/install | 87 +++++++++-------------- 3 files changed, 105 insertions(+), 77 deletions(-) diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 2b3fb5cf0..6a731bb93 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -501,11 +501,13 @@ jobs: run: | import os import pathlib + import re import subprocess artifact_path = pathlib.Path(os.environ["DIST_DIR"]) / os.environ["ARTIFACT_RELATIVE_PATH"] - signature_path = artifact_path.with_suffix(artifact_path.suffix + ".sig") - certificate_path = artifact_path.with_suffix(artifact_path.suffix + ".pem") + signature_path = artifact_path.with_name(artifact_path.name + ".sig") + certificate_path = artifact_path.with_name(artifact_path.name + ".pem") + repository_pattern = re.escape(os.environ["GITHUB_REPOSITORY"]) certificate = certificate_path.read_text(encoding="utf-8") if "-----BEGIN CERTIFICATE-----" not in certificate or "-----END CERTIFICATE-----" not in certificate: @@ -513,22 +515,26 @@ jobs: "cosign certificate output is not PEM text; refusing to publish wrapped certificate payload", ) - subprocess.run( - [ - "./cosign", - "verify-blob", - "--certificate", - str(certificate_path), - "--signature", - str(signature_path), - "--certificate-oidc-issuer", - "https://token.actions.githubusercontent.com", - "--certificate-identity-regexp", - f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/\\.github/workflows/publish-plugins.yml@.*", - str(artifact_path), - ], - check=True, - ) + try: + subprocess.run( + [ + "./cosign", + "verify-blob", + "--certificate", + str(certificate_path), + "--signature", + str(signature_path), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity-regexp", + f"https://github.com/{repository_pattern}/\\.github/workflows/publish-plugins\\.yml@.*", + str(artifact_path), + ], + check=True, + timeout=30, + ) + except subprocess.TimeoutExpired as error: + raise SystemExit("cosign verify-blob timed out after 30 seconds") from error - name: 🚀 Optional publish plugin artifact to OCI if: ${{ steps.meta.outputs.oci_repository != '' }} diff --git a/clients/agent-runtime/src/plugins/mod.rs b/clients/agent-runtime/src/plugins/mod.rs index 627828f16..21e1e7306 100644 --- a/clients/agent-runtime/src/plugins/mod.rs +++ b/clients/agent-runtime/src/plugins/mod.rs @@ -717,6 +717,30 @@ impl PluginManager { ); } + let (signature_bytes, certificate_bytes) = + normalize_signature_bundle(&signature_bytes, &certificate_bytes).with_context(|| { + format!( + "Plugin '{}' signature bundle normalization failed using local installation metadata", + plugin.id + ) + })?; + + if signature_bytes.is_empty() || signature_bytes.len() > MAX_SIGNATURE_BYTES { + bail!( + "Plugin '{}' signature file has invalid size after normalization ({} bytes)", + plugin.id, + signature_bytes.len() + ); + } + + if certificate_bytes.is_empty() || certificate_bytes.len() > MAX_CERTIFICATE_BYTES { + bail!( + "Plugin '{}' certificate file has invalid size after normalization ({} bytes)", + plugin.id, + certificate_bytes.len() + ); + } + verify_blob_with_cosign( artifact_bytes, &signature_bytes, @@ -1615,16 +1639,16 @@ fn validate_manifest_asset_source( Ok(()) } +/// Verifies a plugin artifact with cosign. +/// +/// Callers must pass a normalized signature/certificate payload (for example, +/// already handled for base64 wrappers and PEM normalization). fn verify_blob_with_cosign( artifact: &[u8], signature: &[u8], certificate: &[u8], identity_regex: &str, ) -> Result<()> { - let (normalized_signature, normalized_certificate) = - normalize_signature_bundle(signature, certificate) - .context("Failed to normalize plugin signature bundle")?; - let temp_dir = std::env::temp_dir().join(format!("corvus-plugin-verify-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&temp_dir).with_context(|| { @@ -1640,8 +1664,8 @@ fn verify_blob_with_cosign( let verify_result = (|| -> Result<()> { write_private_file(&artifact_path, artifact)?; - write_private_file(&signature_path, &normalized_signature)?; - write_private_file(&certificate_path, &normalized_certificate)?; + write_private_file(&signature_path, signature)?; + write_private_file(&certificate_path, certificate)?; let mut command = Command::new("cosign"); command @@ -1738,7 +1762,22 @@ fn contains_pem_certificate_markers(payload: &[u8]) -> bool { return false; }; let trimmed = text.trim(); - trimmed.contains("-----BEGIN CERTIFICATE-----") && trimmed.contains("-----END CERTIFICATE-----") + let begin_marker = "-----BEGIN CERTIFICATE-----"; + let end_marker = "-----END CERTIFICATE-----"; + + let Some(begin_index) = trimmed.find(begin_marker) else { + return false; + }; + let Some(end_index) = trimmed.find(end_marker) else { + return false; + }; + + if begin_index >= end_index { + return false; + } + + let content_start = begin_index + begin_marker.len(); + content_start < end_index } fn looks_like_cosign_signature_text(payload: &str) -> bool { diff --git a/clients/web/apps/marketing/public/install b/clients/web/apps/marketing/public/install index 34886230f..b00a5319c 100755 --- a/clients/web/apps/marketing/public/install +++ b/clients/web/apps/marketing/public/install @@ -20,6 +20,7 @@ REQUESTED_INSTALL_DIR="${CORVUS_INSTALL_DIR:-}" AUTO_APPROVE="${CORVUS_YES:-0}" RUN_ONBOARD="${CORVUS_NO_ONBOARD:-0}" SKIP_CHECKSUM="${CORVUS_SKIP_CHECKSUM:-0}" +COSIGN_AVAILABLE="1" OS_TYPE="" LINUX_DISTRO="" @@ -126,10 +127,6 @@ require_supported_shell() { [ -n "${BASH_VERSION:-}" ] || die "This installer requires bash." } -require_dependencies() { - has_cmd curl || die "curl is required. Please install curl and retry." -} - detect_platform() { local uname_s="" @@ -212,6 +209,9 @@ can_auto_install_dependency() { esac ;; apt-get|dnf|yum|pacman) + if [ "$dep" = "cosign" ]; then + return 1 + fi return 0 ;; esac @@ -219,6 +219,20 @@ can_auto_install_dependency() { return 1 } +run_privileged() { + if [ "${1:-}" = "--no-sudo" ]; then + shift + "$@" + return + fi + + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + install_dep_with_pkg_mgr() { local dep="$1" @@ -226,10 +240,10 @@ install_dep_with_pkg_mgr() { brew) case "$dep" in curl) - brew install curl + run_privileged --no-sudo brew install curl ;; cosign) - brew install cosign + run_privileged --no-sudo brew install cosign ;; *) return 1 @@ -239,76 +253,42 @@ install_dep_with_pkg_mgr() { apt-get) case "$dep" in sha256) - if [ "$(id -u)" -eq 0 ]; then - apt-get update -qq - apt-get install -y -qq coreutils - else - sudo apt-get update -qq - sudo apt-get install -y -qq coreutils - fi + run_privileged apt-get update -qq + run_privileged apt-get install -y -qq coreutils ;; *) - if [ "$(id -u)" -eq 0 ]; then - apt-get update -qq - apt-get install -y -qq "$dep" - else - sudo apt-get update -qq - sudo apt-get install -y -qq "$dep" - fi + run_privileged apt-get update -qq + run_privileged apt-get install -y -qq "$dep" ;; esac ;; dnf) case "$dep" in sha256) - if [ "$(id -u)" -eq 0 ]; then - dnf install -y -q coreutils - else - sudo dnf install -y -q coreutils - fi + run_privileged dnf install -y -q coreutils ;; *) - if [ "$(id -u)" -eq 0 ]; then - dnf install -y -q "$dep" - else - sudo dnf install -y -q "$dep" - fi + run_privileged dnf install -y -q "$dep" ;; esac ;; yum) case "$dep" in sha256) - if [ "$(id -u)" -eq 0 ]; then - yum install -y -q coreutils - else - sudo yum install -y -q coreutils - fi + run_privileged yum install -y -q coreutils ;; *) - if [ "$(id -u)" -eq 0 ]; then - yum install -y -q "$dep" - else - sudo yum install -y -q "$dep" - fi + run_privileged yum install -y -q "$dep" ;; esac ;; pacman) case "$dep" in sha256) - if [ "$(id -u)" -eq 0 ]; then - pacman -Sy --noconfirm coreutils - else - sudo pacman -Sy --noconfirm coreutils - fi + run_privileged pacman -S --noconfirm coreutils ;; *) - if [ "$(id -u)" -eq 0 ]; then - pacman -Sy --noconfirm "$dep" - else - sudo pacman -Sy --noconfirm "$dep" - fi + run_privileged pacman -S --noconfirm "$dep" ;; esac ;; @@ -364,7 +344,7 @@ ensure_dependency() { print_warn "Missing required dependency: ${human_name}" - if ! detect_package_manager; then + if [ -z "$PKG_MGR" ] && ! detect_package_manager; then print_error "No compatible package manager detected for this system." print_dependency_manual_help "$dep" return 1 @@ -441,7 +421,10 @@ preflight_dependencies() { ensure_dependency "curl" "curl" || return 1 ensure_dependency "tar" "tar" || return 1 ensure_dependency "sha256" "sha256sum or shasum" || return 1 - ensure_dependency "cosign" "cosign" || return 1 + if ! ensure_dependency "cosign" "cosign"; then + COSIGN_AVAILABLE="0" + print_warn "cosign is not available. Corvus installation can continue, but plugin signature verification will fail until cosign is installed." + fi } parse_args() { From 280f1ae8f1847ac6310571e497e593c6a4d609fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:43:35 +0100 Subject: [PATCH 3/6] fix(installer): warn when cosign remains unavailable --- clients/web/apps/marketing/public/install | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clients/web/apps/marketing/public/install b/clients/web/apps/marketing/public/install index b00a5319c..63e68dc7b 100755 --- a/clients/web/apps/marketing/public/install +++ b/clients/web/apps/marketing/public/install @@ -840,6 +840,11 @@ post_install_steps() { resolved_version="$($resolved_cmd --version 2>/dev/null || echo 'installed')" print_success "Corvus CLI available: ${resolved_version}" + if [ "$COSIGN_AVAILABLE" != "1" ]; then + print_warn "cosign is not installed. Plugin signature verification will fail until cosign is installed." + print_info "Install instructions: https://docs.sigstore.dev/cosign/system_config/installation/" + fi + if [ "$RUN_ONBOARD" = "1" ]; then print_info "Onboarding skipped (--no-onboard or CORVUS_NO_ONBOARD=1)." return From 67454a7315cc58ed1adacc54c621a2c6de0b0bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:05:26 +0100 Subject: [PATCH 4/6] fix(review): apply follow-up nitpicks for installer and signature path --- .github/workflows/publish-plugins.yml | 2 +- clients/agent-runtime/src/plugins/mod.rs | 29 +++++++++++++++-------- clients/web/apps/marketing/public/install | 7 ++++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 6a731bb93..642272701 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -527,7 +527,7 @@ jobs: "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", "--certificate-identity-regexp", - f"https://github.com/{repository_pattern}/\\.github/workflows/publish-plugins\\.yml@.*", + f"https://github\\.com/{repository_pattern}/\\.github/workflows/publish-plugins\\.yml@.*", str(artifact_path), ], check=True, diff --git a/clients/agent-runtime/src/plugins/mod.rs b/clients/agent-runtime/src/plugins/mod.rs index 21e1e7306..a10add69a 100644 --- a/clients/agent-runtime/src/plugins/mod.rs +++ b/clients/agent-runtime/src/plugins/mod.rs @@ -5,6 +5,7 @@ use chrono::Utc; use reqwest::Url; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; @@ -744,7 +745,7 @@ impl PluginManager { verify_blob_with_cosign( artifact_bytes, &signature_bytes, - &certificate_bytes, + certificate_bytes.as_ref(), identity_regex.as_str(), ) .with_context(|| { @@ -1024,7 +1025,7 @@ impl PluginManager { verify_blob_with_cosign( artifact_bytes, &signature, - &certificate, + certificate.as_ref(), identity_regex.as_str(), ) .with_context(|| { @@ -1036,7 +1037,7 @@ impl PluginManager { Ok(Some(VerifiedSignatureBundle { signature, - certificate, + certificate: certificate.into_owned(), })) } @@ -1703,15 +1704,18 @@ fn verify_blob_with_cosign( verify_result } -fn normalize_signature_bundle(signature: &[u8], certificate: &[u8]) -> Result<(Vec, Vec)> { +fn normalize_signature_bundle<'a>( + signature: &'a [u8], + certificate: &'a [u8], +) -> Result<(Vec, Cow<'a, [u8]>)> { let normalized_signature = normalize_signature_payload(signature); let normalized_certificate = normalize_certificate_payload(certificate)?; Ok((normalized_signature, normalized_certificate)) } -fn normalize_certificate_payload(certificate: &[u8]) -> Result> { +fn normalize_certificate_payload(certificate: &[u8]) -> Result> { if contains_pem_certificate_markers(certificate) { - return Ok(certificate.to_vec()); + return Ok(Cow::Borrowed(certificate)); } let as_text = std::str::from_utf8(certificate).context( @@ -1730,7 +1734,7 @@ fn normalize_certificate_payload(certificate: &[u8]) -> Result> { bail!("Decoded certificate payload is missing PEM certificate markers"); } - Ok(decoded) + Ok(Cow::Owned(decoded)) } fn normalize_signature_payload(signature: &[u8]) -> Vec { @@ -1781,7 +1785,7 @@ fn contains_pem_certificate_markers(payload: &[u8]) -> bool { } fn looks_like_cosign_signature_text(payload: &str) -> bool { - if payload.is_empty() || payload.lines().count() != 1 { + if payload.is_empty() || payload.contains('\n') { return false; } @@ -1803,6 +1807,11 @@ fn decode_base64_text(payload: &str) -> Option> { .decode(compact.as_bytes()) .ok() .or_else(|| general_purpose::URL_SAFE.decode(compact.as_bytes()).ok()) + .or_else(|| { + general_purpose::URL_SAFE_NO_PAD + .decode(compact.as_bytes()) + .ok() + }) } struct CommandOutput { @@ -2543,7 +2552,7 @@ mod tests { fn normalize_certificate_payload_accepts_plain_pem() { let pem = b"-----BEGIN CERTIFICATE-----\nZm9v\n-----END CERTIFICATE-----\n"; let normalized = normalize_certificate_payload(pem).expect("plain PEM should be accepted"); - assert_eq!(normalized, pem); + assert_eq!(normalized.as_ref(), pem); } #[test] @@ -2553,7 +2562,7 @@ mod tests { let normalized = normalize_certificate_payload(wrapped.as_bytes()) .expect("base64 wrapped PEM should be normalized"); - assert_eq!(normalized, pem.as_bytes()); + assert_eq!(normalized.as_ref(), pem.as_bytes()); } #[test] diff --git a/clients/web/apps/marketing/public/install b/clients/web/apps/marketing/public/install index 63e68dc7b..063fd0282 100755 --- a/clients/web/apps/marketing/public/install +++ b/clients/web/apps/marketing/public/install @@ -21,6 +21,7 @@ AUTO_APPROVE="${CORVUS_YES:-0}" RUN_ONBOARD="${CORVUS_NO_ONBOARD:-0}" SKIP_CHECKSUM="${CORVUS_SKIP_CHECKSUM:-0}" COSIGN_AVAILABLE="1" +APT_INDEX_UPDATED="0" OS_TYPE="" LINUX_DISTRO="" @@ -251,13 +252,15 @@ install_dep_with_pkg_mgr() { esac ;; apt-get) + if [ "$APT_INDEX_UPDATED" != "1" ]; then + run_privileged apt-get update -qq + APT_INDEX_UPDATED="1" + fi case "$dep" in sha256) - run_privileged apt-get update -qq run_privileged apt-get install -y -qq coreutils ;; *) - run_privileged apt-get update -qq run_privileged apt-get install -y -qq "$dep" ;; esac From 2b36a381fc783f99df899a79165ea107639f150a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:59:18 +0100 Subject: [PATCH 5/6] chore(plugin): bump memory.surreal.graphs to v0.1.2 --- clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.lock | 2 +- clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.lock b/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.lock index b424445ab..58617d52b 100644 --- a/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.lock +++ b/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.lock @@ -82,7 +82,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memory-surreal-graphs-plugin" -version = "0.1.0" +version = "0.1.2" dependencies = [ "hex", "serde", diff --git a/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.toml b/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.toml index 92ed77f15..b515399ea 100644 --- a/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.toml +++ b/clients/agent-runtime/plugins/memory-surreal-graphs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-surreal-graphs-plugin" -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "Apache-2.0" description = "Pilot WASM plugin artifact for Corvus surreal graph memory backend" From 94eb1ae71425ee6ce084609b67d310b8f3442f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:38:13 +0100 Subject: [PATCH 6/6] fix(ci): normalize cosign certificate output before verification --- .github/workflows/publish-plugins.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 642272701..cba0c3879 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -499,6 +499,8 @@ jobs: ARTIFACT_RELATIVE_PATH: ${{ steps.meta.outputs.artifact_relative_path }} shell: python run: | + import base64 + import binascii import os import pathlib import re @@ -509,11 +511,22 @@ jobs: certificate_path = artifact_path.with_name(artifact_path.name + ".pem") repository_pattern = re.escape(os.environ["GITHUB_REPOSITORY"]) - certificate = certificate_path.read_text(encoding="utf-8") + certificate = certificate_path.read_text(encoding="utf-8").strip() if "-----BEGIN CERTIFICATE-----" not in certificate or "-----END CERTIFICATE-----" not in certificate: - raise SystemExit( - "cosign certificate output is not PEM text; refusing to publish wrapped certificate payload", - ) + try: + decoded = base64.b64decode(certificate, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as error: + raise SystemExit( + "cosign certificate output is not PEM text or base64-encoded PEM text", + ) from error + + if "-----BEGIN CERTIFICATE-----" not in decoded or "-----END CERTIFICATE-----" not in decoded: + raise SystemExit( + "cosign certificate output is not PEM text or base64-encoded PEM text", + ) + + normalized_certificate = decoded.rstrip() + "\n" + certificate_path.write_text(normalized_certificate, encoding="utf-8") try: subprocess.run(