diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e34cfe375..5fd00a663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: - name: Run tests run: | - for target in fuzz_openstack_sdk_config fuzz_openstack_sdk_config_yaml fuzz_link_header fuzz_next_page_from_body fuzz_expand_link fuzz_discovery_endpoints fuzz_api_version fuzz_api_error_from_openstack fuzz_auth_error_response fuzz_build_request_url fuzz_state_cache; do + for target in fuzz_openstack_sdk_config fuzz_openstack_sdk_config_yaml fuzz_link_header fuzz_next_page_from_body fuzz_expand_link fuzz_discovery_endpoints fuzz_api_version fuzz_api_error_from_openstack fuzz_auth_error_response fuzz_build_request_url fuzz_state_cache fuzz_wasm_plugin_identity_http_request fuzz_wasm_plugin_sso_build_response fuzz_wasm_plugin_auth_result; do cargo +nightly fuzz run "$target" --features=fuzzing -- -max_total_time=60 done diff --git a/Cargo.lock b/Cargo.lock index 1f17b64c5..34cc01ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3697,7 +3697,7 @@ dependencies = [ "openstack_sdk", "openstack_sdk_core", "openstack_types", - "pem", + "pem 4.0.0", "rsa", "serde_json", "ssh-key", @@ -3872,6 +3872,7 @@ name = "openstack-cli-plugin" version = "0.13.7" dependencies = [ "clap", + "dialoguer", "eyre", "openstack-cli-core", "openstack-sdk-plugin-wasm", @@ -4143,31 +4144,15 @@ name = "openstack-sdk-auth-websso" version = "0.22.6" dependencies = [ "async-trait", - "bytes", "dialoguer", - "form_urlencoded", - "futures", - "futures-util", - "http", - "http-body-util", - "httpmock", - "hyper", - "hyper-util", "inventory", - "open", "openstack-sdk-auth-core", + "openstack-sdk-websso-host", "reqwest", - "reserve-port", "secrecy", "serde", "serde_json", - "serde_urlencoded", - "tempfile", "thiserror 2.0.19", - "tokio", - "tokio-util", - "tracing", - "tracing-test", "url", ] @@ -4340,13 +4325,19 @@ name = "openstack-sdk-plugin-wasm" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.23.0", "chrono", + "dialoguer", "dirs", "extism", "httpmock", "openstack-sdk-auth-core", + "openstack-sdk-websso-host", + "rcgen", "reqwest", + "ring", "secrecy", + "semver", "serde", "serde_json", "sha2 0.11.0", @@ -4355,6 +4346,26 @@ dependencies = [ "tokio", "tracing", "url", + "x509-parser", +] + +[[package]] +name = "openstack-sdk-websso-host" +version = "0.1.0" +dependencies = [ + "bytes", + "form_urlencoded", + "http", + "http-body-util", + "hyper", + "hyper-util", + "open", + "reqwest", + "ring", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", ] [[package]] @@ -4575,6 +4586,7 @@ dependencies = [ "http", "libfuzzer-sys", "openstack-sdk-auth-core", + "openstack-sdk-plugin-wasm", "openstack_sdk", "openstack_sdk_core", "serde_json", @@ -4839,6 +4851,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem" version = "4.0.0" @@ -5510,6 +5532,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem 3.0.6", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -8407,6 +8442,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", "thiserror 1.0.69", "time", @@ -8445,6 +8481,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 5ac630bfb..017844bb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ default-members = [ "sdk/auth-receipt", "sdk/auth-totp", "sdk/auth-websso", + "sdk/websso-host", "sdk/core", "sdk/block-storage", "sdk/container-infrastructure-management", @@ -94,18 +95,22 @@ openstack-cli-placement = { path="cli/placement/", version = "^0.13" } openstack-cli-plugin = { path="cli/plugin/", version = "0.13.7" } openstack-sdk-auth-core = { version = "0.22.6", path = "sdk/auth-core" } openstack-sdk-plugin-wasm = { version = "0.1.0", path = "sdk/plugin-wasm" } +openstack-sdk-websso-host = { version = "0.1.0", path = "sdk/websso-host" } openstack_sdk_core = { version = "0.22.7", path = "sdk/core" } openstack_sdk = { version = "0.22.7", path = "openstack_sdk" } openstack_types = { version = "0.22.7", path = "openstack_types" } openstack-types-core = { version = "0.22", path = "types/core" } pem = { version = "^4.0" } regex = { version = "^1.13" } +rcgen = { version = "^0.13" } +ring = { version = "^0.17" } rsa = { version = "^0.9", features = ["getrandom", "pkcs5"] } ssh-key = { version = "^0.6", features = ["rsa", "encryption"] } reqwest = { version = "^0.13", default-features = false } reserve-port = "^2.5" schemars = { version = "^1.2" } secrecy = { version = "^0.10", features = ["serde"] } +semver = { version = "^1.0" } serde = { version="^1.0", features=["derive"] } serde_json = "^1.0" serde_bytes = "^0.11" @@ -125,6 +130,7 @@ url = { version = "^2.5", features = ["serde"] } webauthn-authenticator-rs = { version = "^0.5", features = ["ctap2", "mozilla", "ui-cli"]} webauthn-rs-proto = { version = "^0.5" } uuid = { version = "^1.24" } +x509-parser = { version = "^0.16" } zeroize = { version = "^1.9" } [profile.dev] diff --git a/cli/plugin/Cargo.toml b/cli/plugin/Cargo.toml index a32969b1d..a7b947bf2 100644 --- a/cli/plugin/Cargo.toml +++ b/cli/plugin/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] clap.workspace = true +dialoguer.workspace = true eyre.workspace = true openstack-cli-core.workspace = true openstack-sdk-plugin-wasm.workspace = true diff --git a/cli/plugin/src/confirm.rs b/cli/plugin/src/confirm.rs new file mode 100644 index 000000000..152566bca --- /dev/null +++ b/cli/plugin/src/confirm.rs @@ -0,0 +1,89 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Shared install/update confirmation UX, used by both `osc plugin install` +//! and `osc plugin update`. + +use std::io::IsTerminal; + +use dialoguer::Confirm; + +use openstack_cli_core::error::OpenStackCliError; +use openstack_sdk_plugin_wasm::registry::{PendingInstall, ProvenanceOutcome}; + +/// The single permission a WASM auth plugin's guest ABI can use today: an +/// HTTP request scoped to the identity provider origin passed at auth time. +/// See `sdk/plugin-wasm/src/plugin.rs` module docs — the guest ABI exposes +/// exactly one host function, so this is not currently derived per-plugin. +const PERMISSIONS_SUMMARY: &str = + "identity_http (HTTP requests to the identity provider origin passed at auth time)"; + +/// Print what's about to be installed/updated — publisher, source repo, the +/// permission this plugin ABI can use, checksum, and the provenance result +/// — then, unless `yes` is set, ask for interactive confirmation. +/// +/// Returns `Ok(true)` to proceed, `Ok(false)` if the user declined. Errors +/// out (rather than prompting) in a non-interactive context without `--yes`, +/// so this never silently hangs on stdin. +pub fn confirm_pending(pending: &PendingInstall, yes: bool) -> Result { + println!("Plugin: {}@{}", pending.name, pending.version); + println!("Source repo: {}", pending.source_repo); + println!("Permissions: {PERMISSIONS_SUMMARY}"); + println!("SHA-256: {}", pending.sha256); + match &pending.provenance { + ProvenanceOutcome::Verified(record) => { + println!( + "Provenance: verified — published by CI in {} (OIDC issuer: {})", + record.source_repo, + record.oidc_issuer.as_deref().unwrap_or("unknown") + ); + if let Some(workflow_ref) = &record.workflow_ref { + println!("Workflow: {workflow_ref}"); + } + } + ProvenanceOutcome::Unverified { reason } => { + println!("Provenance: UNVERIFIED — {reason}"); + } + } + + if yes { + return Ok(true); + } + if !std::io::stdin().is_terminal() { + return Err(eyre::eyre!( + "refusing to install {}@{} without confirmation in a non-interactive context; pass --yes to proceed", + pending.name, + pending.version + ) + .into()); + } + Ok(Confirm::new() + .with_prompt(format!("Install {}@{}?", pending.name, pending.version)) + .interact()?) +} + +/// Unconditionally warn (stderr, not gated by log level, plus a structured +/// `tracing::warn!`) that a plugin is being trusted without provenance +/// verification. Call whenever `--allow-unsigned` is what made an +/// install/update proceed. +pub fn warn_allow_unsigned(name: &str, version: &str) { + eprintln!( + "WARNING: installing {name}@{version} without provenance verification (--allow-unsigned). This plugin's origin has not been cryptographically verified." + ); + tracing::warn!( + name, + version, + "installing plugin without provenance verification (allow_unsigned)" + ); +} diff --git a/cli/plugin/src/install.rs b/cli/plugin/src/install.rs index 54dccc746..5071c9908 100644 --- a/cli/plugin/src/install.rs +++ b/cli/plugin/src/install.rs @@ -12,7 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -//! Install a wasm auth plugin +//! Install a wasm auth plugin, either from the registry or a local file. use std::path::PathBuf; @@ -22,30 +22,63 @@ use tracing::info; use openstack_cli_core::output::{OutputFor, OutputProcessor}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_plugin_wasm::registry::ProvenanceOutcome; use structable::{StructTable, StructTableOptions}; -/// Validate and install a wasm auth plugin. +use crate::confirm; + +/// Install a wasm auth plugin, from the registry by name or from a local +/// `.wasm` file. +/// +/// `osc plugin install ` resolves `` against the registry index +/// (latest version, or `@` to pin a specific one), downloads +/// it, verifies its checksum and — unless `--allow-unsigned` is given — +/// its GitHub artifact attestation, shows what was found, and asks for +/// confirmation (skippable with `--yes`). /// -/// The plugin is loaded and its ABI is validated before anything is copied, -/// so a malformed `.wasm` file is rejected without touching the plugin -/// directory. It is installed as `@` (name comes from the -/// plugin's own ABI; version defaults to `0.0.0` when `--version` is not -/// given) and becomes the active version for that name. Installing over an -/// already-installed `name@version` fails unless `--force` is given. +/// `osc plugin install --file ` installs a local `.wasm` file instead. +/// A local file has no provenance to verify, so this always requires +/// `--allow-unsigned`. In both cases the plugin is loaded and its ABI is +/// validated before anything is written to the plugin directory, and +/// installing over an already-installed `name@version` fails unless +/// `--force` is given. #[derive(Debug, Parser)] pub struct InstallCommand { - /// Path to the `.wasm` auth plugin file to install. - pub file: PathBuf, - - /// Version to record this install under. Defaults to `0.0.0` when not - /// given; multiple versions of the same plugin name may be installed - /// side by side. + /// Plugin to install: `` (latest) or `@` (pinned), + /// resolved against the registry index. Omit when using `--file`. + #[arg(required_unless_present = "file")] + pub spec: Option, + + /// Install from a local `.wasm` file instead of the registry. Local + /// files have no provenance to verify, so this always requires + /// `--allow-unsigned`. + #[arg(long, conflicts_with = "spec")] + pub file: Option, + + /// Version to record a `--file` install under. Defaults to `0.0.0` when + /// not given. Ignored for registry installs — use `@` in + /// the positional argument instead. #[arg(long)] pub version: Option, /// Replace an existing installation of the same `name@version`. #[arg(long)] pub force: bool, + + /// Registry index URL to resolve against. The pinned default is never + /// silently overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, + + /// Proceed without an interactive confirmation prompt. + #[arg(short = 'y', long)] + pub yes: bool, + + /// Install even though the plugin's provenance could not be verified + /// (required for `--file`, since a local file has no provenance to + /// check). Loudly logged. + #[arg(long)] + pub allow_unsigned: bool, } /// Information about an installed plugin. @@ -71,18 +104,84 @@ pub struct InstalledPlugin { impl InstallCommand { /// Perform command action pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { - info!("Install wasm auth plugin from {}", self.file.display()); - let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("install")); - let plugin = openstack_sdk_plugin_wasm::registry::install( - &self.file, - self.version.as_deref(), - self.force, - ) - .map_err(eyre::Report::from)?; - let (major, minor) = plugin.api_version(); + let plugin = if let Some(file) = &self.file { + info!("Install wasm auth plugin from {}", file.display()); + if !self.allow_unsigned { + return Err(eyre::eyre!( + "local --file installs have no provenance to verify; pass --allow-unsigned to install anyway" + ) + .into()); + } + let plugin = openstack_sdk_plugin_wasm::registry::install( + file, + self.version.as_deref(), + self.force, + ) + .map_err(eyre::Report::from)?; + confirm::warn_allow_unsigned( + plugin.name(), + self.version + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::registry::DEFAULT_VERSION), + ); + plugin + } else { + let spec = self + .spec + .as_deref() + .ok_or_else(|| eyre::eyre!("either a plugin name or --file is required"))?; + let (name, version) = match spec.split_once('@') { + Some((n, v)) => (n, Some(v)), + None => (spec, None), + }; + info!("Install wasm auth plugin {spec} from the registry"); + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = + openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + let pending = openstack_sdk_plugin_wasm::registry::plan_remote_install( + name, + version, + registry_url, + &client, + ) + .await + .map_err(eyre::Report::from)?; + + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) + && !self.allow_unsigned + { + return Err(eyre::eyre!( + "refusing to install {}@{} without provenance verification (pass --allow-unsigned to override)", + pending.name, + pending.version + ) + .into()); + } + if !confirm::confirm_pending(&pending, self.yes)? { + return Err(eyre::eyre!("installation cancelled").into()); + } + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) { + confirm::warn_allow_unsigned(&pending.name, &pending.version); + } + + let pinned = version.is_some(); + openstack_sdk_plugin_wasm::registry::finalize_install( + pending, + self.allow_unsigned, + pinned, + self.force, + ) + .map_err(eyre::Report::from)? + }; + + let (major, minor) = plugin.api_version(); let info = InstalledPlugin { name: plugin.name().to_string(), source: plugin.source().display().to_string(), diff --git a/cli/plugin/src/lib.rs b/cli/plugin/src/lib.rs index 1d0c49646..cc883c010 100644 --- a/cli/plugin/src/lib.rs +++ b/cli/plugin/src/lib.rs @@ -23,10 +23,14 @@ use clap::{Parser, Subcommand}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +mod confirm; + pub mod info; pub mod install; pub mod list; pub mod remove; +pub mod search; +pub mod update; pub mod verify; /// WASM auth plugin management @@ -49,6 +53,8 @@ pub enum PluginCommands { Info(info::InfoCommand), Remove(remove::RemoveCommand), Verify(verify::VerifyCommand), + Search(search::SearchCommand), + Update(update::UpdateCommand), } impl PluginCommand { @@ -60,6 +66,8 @@ impl PluginCommand { PluginCommands::Info(cmd) => cmd.take_action(parsed_args).await, PluginCommands::Remove(cmd) => cmd.take_action(parsed_args).await, PluginCommands::Verify(cmd) => cmd.take_action(parsed_args).await, + PluginCommands::Search(cmd) => cmd.take_action(parsed_args).await, + PluginCommands::Update(cmd) => cmd.take_action(parsed_args).await, } } } diff --git a/cli/plugin/src/list.rs b/cli/plugin/src/list.rs index 13a4e7a02..a6bc77dbb 100644 --- a/cli/plugin/src/list.rs +++ b/cli/plugin/src/list.rs @@ -18,7 +18,7 @@ use clap::Parser; use serde::{Deserialize, Serialize}; use tracing::info; -use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::output::{OutputFor, OutputProcessor}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; use structable::{StructTable, StructTableOptions}; @@ -50,6 +50,11 @@ pub struct PluginListEntry { /// When this version was installed. #[structable()] pub installed_at: String, + + /// Whether this entry was trusted without provenance verification + /// (`--allow-unsigned` at install/update time). + #[structable()] + pub allow_unsigned: bool, } /// List installed wasm auth plugins. @@ -66,6 +71,7 @@ impl ListCommand { let lockfile = openstack_sdk_plugin_wasm::registry::installed().map_err(eyre::Report::from)?; + let mut unsigned: Vec = Vec::new(); let data: Vec = lockfile .plugins .values() @@ -75,6 +81,9 @@ impl ListCommand { .get(&entry.name) .map(|v| v == &entry.version) .unwrap_or(false); + if entry.trust.allow_unsigned { + unsigned.push(format!("{}@{}", entry.name, entry.version)); + } serde_json::to_value(PluginListEntry { name: entry.name.clone(), version: entry.version.clone(), @@ -82,10 +91,21 @@ impl ListCommand { source: entry.source.display().to_string(), sha256: entry.sha256.clone(), installed_at: entry.installed_at.to_string(), + allow_unsigned: entry.trust.allow_unsigned, }) }) .collect::>()?; - op.output_list::(data) + op.output_list::(data)?; + + if matches!(op.target, OutputFor::Human) && !unsigned.is_empty() { + println!( + "\n⚠ {} installed plugin{} running without provenance verification (allow_unsigned): {}", + unsigned.len(), + if unsigned.len() == 1 { " is" } else { "s are" }, + unsigned.join(", ") + ); + } + Ok(()) } } diff --git a/cli/plugin/src/remove.rs b/cli/plugin/src/remove.rs index 0dc8cbec6..ae1d76a98 100644 --- a/cli/plugin/src/remove.rs +++ b/cli/plugin/src/remove.rs @@ -66,6 +66,7 @@ impl RemoveCommand { source: entry.source.display().to_string(), sha256: entry.sha256.clone(), installed_at: entry.installed_at.to_string(), + allow_unsigned: entry.trust.allow_unsigned, }) }) .collect::>()?; diff --git a/cli/plugin/src/search.rs b/cli/plugin/src/search.rs new file mode 100644 index 000000000..206034054 --- /dev/null +++ b/cli/plugin/src/search.rs @@ -0,0 +1,91 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Search the plugin registry index. + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use structable::{StructTable, StructTableOptions}; + +/// Search the plugin registry index for plugins whose name or description +/// matches `query`, or list every published plugin when `query` is omitted. +#[derive(Debug, Parser)] +pub struct SearchCommand { + /// Case-insensitive substring to match against plugin name/description. + pub query: Option, + + /// Registry index URL to search. The pinned default is never silently + /// overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, +} + +/// A single matching registry entry. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, StructTable)] +pub struct PluginSearchResult { + /// Plugin name. + #[structable()] + pub name: String, + + /// Human readable description. + #[structable()] + pub description: String, + + /// The version that would be installed by `osc plugin install `. + #[structable()] + pub latest_version: String, + + /// The `owner/repo` the latest version claims to be published from. + #[structable()] + pub source_repo: String, +} + +impl SearchCommand { + /// Perform command action + pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { + info!("Search plugin registry for {:?}", self.query); + + let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("search")); + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + let index = openstack_sdk_plugin_wasm::index::fetch_index(registry_url, &client) + .await + .map_err(eyre::Report::from)?; + + let data: Vec = + openstack_sdk_plugin_wasm::index::search(&index, self.query.as_deref()) + .into_iter() + .map(|entry| { + let latest = + openstack_sdk_plugin_wasm::index::resolve_version(entry, None).ok(); + serde_json::to_value(PluginSearchResult { + name: entry.name.clone(), + description: entry.description.clone(), + latest_version: latest.map(|v| v.version.clone()).unwrap_or_default(), + source_repo: latest.map(|v| v.source_repo.clone()).unwrap_or_default(), + }) + }) + .collect::>()?; + + op.output_list::(data) + } +} diff --git a/cli/plugin/src/update.rs b/cli/plugin/src/update.rs new file mode 100644 index 000000000..e28a99d83 --- /dev/null +++ b/cli/plugin/src/update.rs @@ -0,0 +1,180 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Update installed wasm auth plugin(s) to the latest registry version. + +use std::io::IsTerminal; + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_plugin_wasm::registry::{ProvenanceOutcome, UpdateOutcome}; +use structable::{StructTable, StructTableOptions}; + +use crate::confirm; + +/// Update installed, non-pinned wasm auth plugin(s) to the latest version +/// available in the registry index. +/// +/// Provenance is re-verified fresh for every plugin considered, never reused +/// from a previous install — the same GitHub attestation check `osc plugin +/// install` performs. A plugin installed with an explicit `@version` is +/// "pinned" and is skipped by `--all` (and refused by name) unless +/// reinstalled explicitly via `osc plugin install @`. +#[derive(Debug, Parser)] +pub struct UpdateCommand { + /// Plugin name to update. Omit and pass `--all` to update every + /// installed, non-pinned plugin instead. + #[arg(conflicts_with = "all")] + pub name: Option, + + /// Update every installed, non-pinned plugin. + #[arg(long)] + pub all: bool, + + /// Registry index URL to resolve against. The pinned default is never + /// silently overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, + + /// Proceed without an interactive confirmation prompt for each update. + #[arg(short = 'y', long)] + pub yes: bool, + + /// Allow updating to a version whose provenance could not be verified. + /// Loudly logged. A plugin whose new version fails verification is + /// skipped when this is not given. + #[arg(long)] + pub allow_unsigned: bool, +} + +/// The outcome of one plugin's update attempt. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, StructTable)] +pub struct PluginUpdateResult { + /// Plugin name. + #[structable()] + pub name: String, + + /// What happened: `updated`, `up-to-date`, `declined`, `skipped + /// (pinned)`, or `not in registry index`. + #[structable()] + pub status: String, + + /// The version that was active before this update attempt. + #[structable()] + pub from_version: String, + + /// The version now active (equal to `from_version` unless `status` is + /// `updated`). + #[structable()] + pub to_version: String, +} + +impl UpdateCommand { + /// Perform command action + pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { + info!("Update installed wasm auth plugin(s)"); + + let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("update")); + + if self.name.is_none() && !self.all { + return Err(eyre::eyre!("update requires either a plugin name or --all").into()); + } + if !self.yes && !std::io::stdin().is_terminal() { + return Err(eyre::eyre!( + "refusing to update without confirmation in a non-interactive context; pass --yes to proceed" + ) + .into()); + } + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + + let yes = self.yes; + let allow_unsigned = self.allow_unsigned; + let outcomes = openstack_sdk_plugin_wasm::registry::update( + self.name.as_deref(), + self.all, + registry_url, + &client, + allow_unsigned, + |pending| { + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) && !allow_unsigned { + eprintln!( + "refusing to update {}@{} without provenance verification (pass --allow-unsigned to override); skipping", + pending.name, pending.version + ); + return false; + } + let proceed = confirm::confirm_pending(pending, yes).unwrap_or_else(|e| { + eprintln!("{e}"); + false + }); + if proceed && matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) { + confirm::warn_allow_unsigned(&pending.name, &pending.version); + } + proceed + }, + ) + .await + .map_err(eyre::Report::from)?; + + let data: Vec = outcomes + .into_iter() + .map(|outcome| { + let entry = match outcome { + UpdateOutcome::UpToDate { name, version } => PluginUpdateResult { + name, + status: "up-to-date".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::Updated { name, from, to } => PluginUpdateResult { + name, + status: "updated".into(), + from_version: from, + to_version: to, + }, + UpdateOutcome::Declined { name, version } => PluginUpdateResult { + name, + status: "declined".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::SkippedPinned { name, version } => PluginUpdateResult { + name, + status: "skipped (pinned)".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::NotInIndex { name } => PluginUpdateResult { + name, + status: "not in registry index".into(), + from_version: String::new(), + to_version: String::new(), + }, + }; + serde_json::to_value(entry) + }) + .collect::>()?; + + op.output_list::(data) + } +} diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index e585d1d2e..44bda4270 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -11,6 +11,9 @@ # Components - [Rust SDK](./sdk.md) +- [WASM Auth Plugins](./plugins.md) + - [Plugin author guide](./plugins/author-guide.md) + - [Registry governance](./plugins/registry-governance.md) --- diff --git a/doc/src/plugins.md b/doc/src/plugins.md new file mode 100644 index 000000000..426482b38 --- /dev/null +++ b/doc/src/plugins.md @@ -0,0 +1,121 @@ +# WASM Auth Plugins + +`osc` supports authentication methods beyond the ones compiled in, loaded at +runtime from [Extism](https://extism.org/) (WebAssembly) modules. This lets a +third party ship a new `auth_type` — for example a corporate SSO flow, or an +identity provider-specific token exchange — without a fork of `osc` or a +recompile, and without ever giving that third party's code a socket, the +filesystem, or environment variables: every WASM plugin runs in a sandbox +that structurally cannot perform I/O other than through the narrow, +host-mediated capabilities described below. + +This page covers using plugins as an `osc` operator. If you want to write +one, see the [plugin author guide](./plugins/author-guide.md). For how +plugins get into the registry `osc plugin install` resolves against, see +[registry governance](./plugins/registry-governance.md). + +## The sandbox, briefly + +A loaded plugin gets: + +- A bounded amount of linear memory (16 MiB) and a per-call timeout (20s). +- No filesystem, no environment variables, no WASI. +- For the `auth` ABI flavor: a single host function, + `identity_http_request`, that proxies HTTP requests to the identity + endpoint `osc` resolved for the configured cloud — never to a URL the + plugin picks itself. +- For the `sso` ABI flavor: no host function at all. The plugin never holds + a socket or a browser-opening capability; it only ever computes the + identity-provider URL to open and, later, parses an already + CSRF-validated callback. The local callback listener, the anti-CSRF + `state` check, and the actual browser launch are all owned by `osc` + itself. + +Either way, the plugin cannot reach any host other than the one `osc` +explicitly hands it, and it cannot exfiltrate data through any channel other +than the token it ultimately returns. + +## Installing a plugin + +```console +$ osc plugin search sso +$ osc plugin install example_auth +$ osc plugin install example_auth@1.2.0 # pin a specific version +$ osc plugin install --file ./my-plugin.wasm --allow-unsigned +``` + +`osc plugin install [@version]` resolves the name against a registry +index (`--registry-url` to point at a different one than the built-in +default), downloads the `.wasm` artifact, and before ever trusting it: + +1. Checks the downloaded bytes' SHA-256 against what the registry index + declares, before anything touches disk. +2. Verifies the artifact's GitHub attestation (produced by `actions/attest` + in the plugin's own CI) proves it was actually built and published from + the repository the index claims, not just that the bytes are + internally consistent. See + [Trust model](#trust-model) below for exactly what this does and does + not prove. +3. Shows you the source repository, the checksum, and the provenance + result, and asks for confirmation (skip with `--yes`). + +Installing from a local file (`--file`) skips steps 1–2 entirely — there is +no registry entry and no attestation to check for a file on your own disk — +so it always requires `--allow-unsigned` and is always loudly logged as +such. + +Other commands: + +| Command | Purpose | +| --- | --- | +| `osc plugin search [query]` | List registry entries matching `query` (name/description substring), or every entry if omitted. | +| `osc plugin list` | List installed `name@version` pairs, which one is active, and whether it was trusted via `--allow-unsigned`. | +| `osc plugin info ` | Show every installed version of a plugin. | +| `osc plugin update [ \| --all]` | Re-resolve non-pinned installs against the registry and upgrade. Provenance is re-checked fresh every time, never reused from the original install. | +| `osc plugin verify ` | Re-check installed file(s) against the SHA-256 recorded in the lockfile at install time — catches on-disk tampering or corruption after install. | +| `osc plugin remove [--version]` | Remove one or every installed version of a plugin. | + +## Trust model + +Two independent checks stand between a downloaded `.wasm` file and it being +loaded and used for authentication: + +- **Checksum**: the bytes must match the SHA-256 the registry index + declares for that version. This only proves the download wasn't + corrupted or tampered with in transit relative to what the index says — + it says nothing about whether the index itself can be trusted. +- **Provenance**: the artifact's GitHub attestation must verify against a + vendored, pinned Fulcio root/intermediate CA, and the attestation's + embedded identity (OIDC issuer + `owner/repo`) must match the source + repository the registry index claims for that plugin. This is what + actually proves the file was built by CI in the claimed repository, + rather than uploaded by hand or by an attacker who compromised the + index. + + This verifier deliberately does **not** check Rekor transparency-log + inclusion (the Merkle audit-path proof that the attestation was actually + published to the public log, not just handed to `osc` directly) — see + `sdk/plugin-wasm/src/provenance.rs` for why. In practice this means: a + tampered or entirely unattested plugin still fails closed, but a + withheld-from-the-log attestation would not be caught. + +If either check fails, `osc plugin install`/`update` refuse outright unless +you pass `--allow-unsigned`, which is always logged (both to the terminal +and in structured logs) and always recorded in the lockfile, so +`osc plugin list` continues to show you which installed plugins are running +without full verification. + +## Using an installed plugin + +Once installed, a plugin's declared auth method(s) become usable exactly +like a built-in one — set `auth_type` in `clouds.yaml` (or pass it however +you normally configure authentication) to the method name the plugin +reports (visible via `osc plugin info ` or `osc plugin install`'s +output). + +For an `sso`-flavor plugin, `osc` will additionally ask for confirmation +before opening your browser, showing you the exact URL it's about to open. +Declining, or the plugin returning anything other than an `https://` URL on +the callback address `osc` itself bound, is refused outright with no +override — see the [author guide](./plugins/author-guide.md#the-sso-abi-flavor) +for the mechanics. diff --git a/doc/src/plugins/author-guide.md b/doc/src/plugins/author-guide.md new file mode 100644 index 000000000..9ec5a768e --- /dev/null +++ b/doc/src/plugins/author-guide.md @@ -0,0 +1,173 @@ +# Plugin author guide + +This page describes the guest ABI a WASM auth plugin must implement, and how +to build, test, and publish one. For the operator-facing view (installing, +trust model), see [WASM Auth Plugins](../plugins.md). + +A plugin is a single `.wasm` module targeting `wasm32-unknown-unknown`, +built against [Extism's PDK](https://extism.org/docs/quickstart/plugin-quickstart) +(`extism-pdk` for Rust, or any other language the PDK supports — the ABI is +just exported functions taking and returning strings, so it isn't Rust-only). +It runs with no filesystem, no environment variables, no WASI, 16 MiB of +linear memory, and a 20-second per-call timeout. It cannot open a socket or +resolve DNS itself; the only way it ever touches the network is the +host-mediated capability described below. + +## Common exports (every plugin) + +Every conforming module exports these four functions regardless of ABI +flavor: + +| Export | Signature | Purpose | +| --- | --- | --- | +| `plugin_abi_version` | `(_: string) -> string` | Must return the literal `"1"`. This is the only ABI version `osc` currently understands; a mismatch is rejected at load time before anything else runs. | +| `auth_supported_methods` | `(_: string) -> string` | JSON array of the `auth_type` name(s) this plugin implements, e.g. `["my_corp_sso"]`. Must be non-empty. | +| `auth_api_version` | `(_: string) -> string` | JSON `[major, minor]` pair, informational. | +| `auth_requirements` | `(hints: string) -> string` | `hints` is a JSON value or the literal `null`. Returns a JSON Schema object describing the fields this auth method needs from the user, in the same shape `OpenStackAuthType::requirements` produces for compiled-in auth types. | + +At load time `osc` calls these four in order, validates the answers, and +then inspects which of the two ABI flavors below the module additionally +exports (via `extism::Plugin::function_exists`) — a module must implement +**exactly one**; exporting both, or neither, is rejected. + +## The `auth` ABI flavor + +For non-interactive auth methods (token exchange, credential-based flows, +anything that doesn't need a browser). + +- **`auth(request: string) -> string`** — `request` is a JSON object: + + ```json + {"identity_url": "...", "values": {...}, "scope": {...}, "hints": {...}} + ``` + + Returns either: + + ```json + {"ok": {"token": "...", "auth_info": null}} + ``` + + or: + + ```json + {"error": "human readable message"} + ``` + + This is the only export in this flavor, and it's also the only place a + plugin may perform outbound HTTP — and only through the host-provided + `identity_http_request` import, never directly (there is no direct-socket + capability to use even if you wanted to). + +### `identity_http_request` + +The one host function available to `auth`-flavor plugins. It proxies a +request to the identity endpoint `osc` already resolved for the configured +cloud — never to a URL the plugin picks itself: + +```json +// guest -> host +{"method": "POST", "path": "/v3/auth/tokens", "headers": {...}, "body": "..."} +``` + +```json +// host -> guest +{"status": 201, "headers": {...}, "body": "..."} +``` + +`path` must be relative (start with `/`) and is resolved against the bound +identity origin (scheme + host + port only) — there is no field for +supplying a different host, so a plugin structurally cannot be redirected +into calling anything other than the identity endpoint it was invoked for. + +## The `sso` ABI flavor + +For interactive, browser-based (WebSSO-style) auth methods. Both exports +are pure functions: no I/O capability is available to this flavor at all. +The host owns the local callback listener, the anti-CSRF `state` check, and +the actual browser-launch step — the guest only ever computes strings from +strings. + +- **`sso_build_request(request: string) -> string`** — `request` is: + + ```json + {"identity_url": "...", "callback_url": "...", "values": {...}, "scope": {...}, "hints": {...}} + ``` + + `callback_url` is the host-bound local callback URL, with the anti-CSRF + `state` token already embedded — the plugin doesn't generate or see the + raw CSRF secret, it just has to make sure the identity provider redirects + back to this exact URL. Returns: + + ```json + {"url": "https://idp.example.com/authorize?...", "redirect_host": "127.0.0.1:PORT"} + ``` + + `url` is the page the host will open in the user's browser after showing + it to the user for confirmation. `redirect_host` is the `host:port` + authority the plugin configured as the identity provider's redirect + target. Before opening anything, the host independently verifies that + `url`'s scheme is `https` and that `redirect_host` exactly matches the + authority of the callback listener it itself bound — a plugin that + returns a URL pointing at a different, undeclared redirect host is + rejected outright, with **no override**, since this is the one place a + compromised plugin could otherwise redirect a real user's browser + somewhere attacker-controlled. + +- **`sso_parse_callback(callback: string) -> string`** — `callback` is: + + ```json + {"params": {"code": "...", "state": "..."}} + ``` + + the form fields from the callback POST, handed to the guest only *after* + the host has already validated the `state` token itself. Returns the same + `{"ok": ...} | {"error": ...}` shape as `auth`. + +## Building + +```console +$ rustup target add wasm32-unknown-unknown +$ cargo build --release --target wasm32-unknown-unknown +``` + +Using `extism-pdk`, the common exports and one ABI flavor's exports are +ordinary `#[plugin_fn]`-annotated functions returning `FnResult` (or +a PDK JSON-typed wrapper) — see the +[Extism Rust PDK docs](https://github.com/extism/rust-pdk) for the exact +macro shape. There's nothing `osc`-specific about the build step beyond the +export names and JSON shapes above. + +## Testing locally + +Before publishing, install straight from the built artifact with +`--allow-unsigned` (a local file has no attestation to check, so this is +always required — see [Trust model](../plugins.md#trust-model)): + +```console +$ osc plugin install --file ./target/wasm32-unknown-unknown/release/my_plugin.wasm --allow-unsigned +$ osc plugin list +$ osc --os-auth-type my_corp_sso ... # exercise it +``` + +`osc plugin verify ` re-checks the installed file's SHA-256 against +the lockfile at any point, useful for confirming nothing got corrupted +during iteration. + +## Publishing + +1. In your own repository's CI, build the release artifact and attest it + with [`actions/attest`](https://github.com/actions/attest) — the same + action this repository's own release workflow uses + (`.github/workflows/release.yml`). This is what lets `osc` later prove + the `.wasm` file it downloaded actually came from a build in your + repository, not from an attacker who compromised the registry index. +2. Publish the artifact (a GitHub release asset is the natural choice) and + compute its SHA-256. +3. Open a pull request against this repository adding your plugin's + `name`/`versions[]` entry to `plugins/registry/index.json`. See + [Registry governance](./registry-governance.md) for what reviewers check + before merging. + +Once merged, `osc plugin search`/`install ` picks it up from the +default registry immediately — there's no separate publish step on the +`osc` side. diff --git a/doc/src/plugins/registry-governance.md b/doc/src/plugins/registry-governance.md new file mode 100644 index 000000000..d6f5c450f --- /dev/null +++ b/doc/src/plugins/registry-governance.md @@ -0,0 +1,105 @@ +# Registry governance + +`osc plugin search`/`install`/`update` resolve against a plugin index — +by default `plugins/registry/index.json` in this repository, served over +plain HTTPS via `raw.githubusercontent.com` (see +[the registry directory's own README](https://github.com/gtema/openstack/blob/main/plugins/registry/README.md) +for the exact JSON schema). This page describes how an entry gets into that +index, what stops a bad entry from being trusted even if it does, and how a +published plugin gets removed. + +## Why the index itself doesn't need to be trusted blindly + +The index is a plain JSON file in a normal git repository, merged through +normal pull-request review like any other change here. It is **not** a +security boundary on its own — anyone who can get a PR merged (including, +in principle, a reviewer who makes a mistake) can add or edit an entry. +What actually stops a malicious or mistaken index entry from being trusted +is downstream of the index, at install time: + +- **Checksum**: the downloaded bytes must match the `sha256` the index + declares, checked before anything touches disk. +- **Provenance**: the artifact's GitHub attestation must verify against a + pinned Fulcio trust root, and the attestation's embedded identity + (`owner/repo`) must match the index's `source_repo` field for that + version. + +So an index entry that points `download_url`/`sha256` at a legitimate +release, but lies about `source_repo`, fails closed at install time — the +downloaded bytes' real attestation won't match the claimed repo. An index +entry pointing at an attacker-controlled URL with a self-consistent +`sha256` still fails unless that URL's artifact also carries a valid +attestation naming the claimed `source_repo`, which requires control of +that repository's CI, not just of the index text. Full detail on exactly +what the provenance check does and does not prove is in +[Trust model](../plugins.md#trust-model). + +This is a deliberate design point: **reviewing an index PR is a +plausibility check, not the trust boundary itself.** It means review can +stay lightweight without turning into the sole thing standing between users +and a malicious plugin. + +## What a reviewer checks before merging an index PR + +1. **The PR only touches `plugins/registry/index.json`** (and, for a new + plugin, its `name`/`description`), not unrelated files. +2. **`source_repo` is a real, publicly reachable repository** that plausibly + belongs to whoever opened the PR — a repository under someone else's + account/org, or a private repository, is not an appropriate provenance + target for a public registry entry. +3. **`download_url` actually resolves to a release asset in that + `source_repo`** (typically a GitHub Releases download URL), and the + declared `sha256` matches what's actually at that URL — a reviewer can + check this by downloading the asset and hashing it locally. +4. **The repository's release workflow attests the artifact** with + `actions/attest` (or an equivalent Sigstore-based GitHub attestation) — + visible in the repo's own Actions workflow file. An entry whose CI + doesn't attest will simply fail closed for every installer later + (`--allow-unsigned` required), which is safe but a poor experience, so + reviewers should ask for this before merging rather than after users + start filing confused issues. +5. **`abi_version` matches what the artifact actually reports** — a + mismatch here is caught automatically at install time, but catching it + in review saves a round trip. +6. For an update to an *existing* plugin (a new `versions[]` entry, not a + new plugin), the new entry's `source_repo` should match prior versions' + unless the PR explains why ownership moved (e.g. a transferred + repository) — a silent `source_repo` change on an established plugin + name is exactly the shape a supply-chain compromise would take, so it + gets a closer look, not a rubber stamp. + +None of this review substitutes for the checksum/provenance checks `osc` +itself performs — it exists to keep obviously broken or obviously +inappropriate entries from being merged at all, and to give users installing +a plugin for the first time (who see the source repo and provenance result +before confirming, per [Installing a plugin](../plugins.md#installing-a-plugin)) +something meaningful to look at. + +## Removal and revocation + +There is currently no automated revocation mechanism — removing or editing +an entry in `index.json` is, like adding one, a normal pull request. If a +published plugin turns out to be malicious, broken, or abandoned: + +- Removing its entry (or a specific bad `versions[]` entry) from the index + stops new `osc plugin install`/`update` calls from resolving it. This + does **not** reach back into anyone's already-installed copy — `osc + plugin remove ` on the affected machine is still up to the user (or + their own tooling) to run. +- For an actively malicious release, opening an issue/PR promptly and + flagging it for expedited review is preferable to waiting for a routine + review cycle — the index has no separate "yank" mechanism, only "the + entry is gone from `main`" the next time someone fetches it. + +## Scope of this bootstrap registry + +This index living in `gtema/openstack` rather than a dedicated registry +repository is a bootstrap choice, not a schema constraint — nothing about +the index format or the fetch/verification code is tied to this +repository. `--registry-url` already lets anyone point at a different +index today (their own private registry, an alternate community one, etc.), +subject to the same checksum/provenance checks on the client side +regardless of which index served the entry. If plugin release cadence ever +needs to move independently of `osc`'s own releases, the default index can +move to a dedicated repository with no code changes beyond the pinned +default URL. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 36a41642f..e70274c81 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -19,9 +19,11 @@ fuzzing = [ "dep:tempfile", "dep:openstack_sdk_core", "dep:openstack-sdk-auth-core", + "dep:openstack-sdk-plugin-wasm", "openstack_sdk/fuzzing", "openstack_sdk_core/fuzzing", "openstack-sdk-auth-core/fuzzing", + "openstack-sdk-plugin-wasm/fuzzing", ] [package.metadata.dist] @@ -33,6 +35,7 @@ bytes.workspace = true http.workspace = true libfuzzer-sys = { version = "0.4", optional = true } openstack-sdk-auth-core = { workspace = true, optional = true } +openstack-sdk-plugin-wasm = { workspace = true, optional = true } openstack_sdk_core = { workspace = true, optional = true } serde_json.workspace = true tempfile = { workspace = true, optional = true } @@ -128,3 +131,27 @@ required-features = ["fuzzing"] test = false doc = false bench = false + +[[bin]] +name = "fuzz_wasm_plugin_identity_http_request" +path = "fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_wasm_plugin_sso_build_response" +path = "fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_wasm_plugin_auth_result" +path = "fuzz_targets/fuzz_wasm_plugin_auth_result.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs new file mode 100644 index 000000000..c8cb0ffe5 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs @@ -0,0 +1,20 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::plugin::fuzz_parse_auth_result; + +extern crate openstack_sdk_plugin_wasm; + +// Every guest export that finishes an auth attempt (`auth`, and +// `sso_parse_callback`) returns its result as an `AuthResultMsg` JSON string +// that the host deserializes. This is the last guest-controlled parse before +// the host either mints an `Auth::AuthToken` or surfaces an error message to +// the caller, so it must never panic regardless of what the guest returns -- +// a compromised or simply buggy plugin is the threat model, not a +// well-behaved one. + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + fuzz_parse_auth_result(s); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs new file mode 100644 index 000000000..8676b0a71 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs @@ -0,0 +1,59 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::fuzz_identity_http_request_parsing; +use url::Url; + +extern crate openstack_sdk_plugin_wasm; + +// `identity_http_request` is the one host function exposed to every WASM auth +// plugin -- the sole point where guest-controlled bytes cross the Extism +// boundary into host code. Its request-parsing/validation step (JSON decode, +// `path` must be relative, URL join against the bound identity origin, method +// parse) runs on every call before any network I/O, so it must never panic on +// adversarial input, however the plugin was built. + +#[derive(Debug, Arbitrary)] +enum FuzzRequest { + /// Fully arbitrary text: covers "doesn't even parse as the request shape". + Raw(String), + /// A well-formed `HttpRequestMsg` JSON shape with fuzzed field values, + /// reaching the relative-path check, URL join, and method parse. + Structured { + method: String, + path: String, + headers: Vec<(String, String)>, + body: Option, + }, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + origin_host: String, + request: FuzzRequest, +} + +fuzz_target!(|input: FuzzInput| { + let Ok(origin) = Url::parse(&format!("https://{}", input.origin_host)) else { + return; + }; + + let request_json = match &input.request { + FuzzRequest::Raw(s) => s.clone(), + FuzzRequest::Structured { + method, + path, + headers, + body, + } => serde_json::json!({ + "method": method, + "path": path, + "headers": headers.iter().cloned().collect::>(), + "body": body, + }) + .to_string(), + }; + + fuzz_identity_http_request_parsing(&origin, &request_json); +}); diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs new file mode 100644 index 000000000..37741f84e --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs @@ -0,0 +1,41 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::plugin::fuzz_validate_sso_build_response; + +extern crate openstack_sdk_plugin_wasm; + +// `sso_build_request`'s response is the guest's one chance to steer where a +// real browser gets opened, so the host validates it before ever prompting +// the user: the URL must parse and be `https`, and the declared +// `redirect_host` must exactly match the host-bound callback listener's own +// authority. A malicious or buggy plugin fully controls both strings; this +// target checks the validation logic never panics on adversarial input, on +// either axis (the response shape and the expected-host comparison). + +#[derive(Debug, Arbitrary)] +enum FuzzBuildOutput { + /// Fully arbitrary text: covers "doesn't even parse as the response shape". + Raw(String), + /// A well-formed `{"url", "redirect_host"}` shape with fuzzed field + /// values, reaching the URL-parse/scheme/host-match checks. + Structured { url: String, redirect_host: String }, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + build_output: FuzzBuildOutput, + expected_redirect_host: String, +} + +fuzz_target!(|input: FuzzInput| { + let build_output = match &input.build_output { + FuzzBuildOutput::Raw(s) => s.clone(), + FuzzBuildOutput::Structured { url, redirect_host } => { + serde_json::json!({"url": url, "redirect_host": redirect_host}).to_string() + } + }; + + fuzz_validate_sso_build_response(&build_output, &input.expected_redirect_host); +}); diff --git a/plugins/registry/README.md b/plugins/registry/README.md new file mode 100644 index 000000000..62dbb172b --- /dev/null +++ b/plugins/registry/README.md @@ -0,0 +1,64 @@ +# osc plugin registry index + +This directory hosts the bootstrap index consulted by `osc plugin search` +and `osc plugin install [@version]`. The default registry URL +(`openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL`) points at +`index.json` in this directory on the default branch of this repository, +fetched over plain HTTPS via `raw.githubusercontent.com`. + +This is a bootstrap: nothing about the index schema or the fetch/verify code +is tied to living in this repo. If plugin release cadence ever needs to move +independently of `osc` releases, the index (and the plugins it points at) can +move to a dedicated repository with no code changes beyond the pinned default +URL — `--registry-url` already lets any URL be used explicitly today. + +## Schema + +```json +{ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Human readable one-liner.", + "versions": [ + { + "version": "1.0.0", + "download_url": "https://github.com///releases/download/v1.0.0/example_auth.wasm", + "sha256": "", + "source_repo": "/", + "abi_version": "1", + "min_cli_version": "0.13.0" + } + ] + } + ] +} +``` + +- `schema_version` — bumped on breaking changes to this shape; `osc` rejects + an index whose `schema_version` it doesn't understand. +- `plugins[].versions[]` — one entry per published version; `osc plugin + install ` without `@version` installs the highest by semver, `osc + plugin update` re-resolves this same way. +- `source_repo` — the GitHub repository (`owner/repo`) whose CI is expected + to have published this exact `.wasm` artifact and its attestation + (`actions/attest`). This is what provenance verification checks the + downloaded file's GitHub attestation against before `osc` trusts it. +- `sha256` — checked against the downloaded bytes before anything touches + disk, independent of and prior to provenance verification. +- `abi_version` — informational; matched against the guest ABI's own + self-reported `plugin_abi_version` after download. +- `min_cli_version` — optional; if set and the running `osc` is older, + install is refused with a clear version-mismatch error rather than a + confusing runtime failure. + +## Publishing a plugin + +A publisher's own CI (in the plugin's own repository) builds and releases +the `.wasm` artifact and attests it with `actions/attest` (the same GitHub +Action already used by this repo's own release workflow, see +`.github/workflows/release.yml`). Getting an entry added to `index.json` +here is a normal PR against this repository, adding the plugin's +`name`/`versions[]` entry with the release's real `download_url` and +`sha256`. diff --git a/plugins/registry/index.json b/plugins/registry/index.json new file mode 100644 index 000000000..ea952a7be --- /dev/null +++ b/plugins/registry/index.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "plugins": [] +} diff --git a/sdk/auth-websso/Cargo.toml b/sdk/auth-websso/Cargo.toml index a8415a0aa..d2153d342 100644 --- a/sdk/auth-websso/Cargo.toml +++ b/sdk/auth-websso/Cargo.toml @@ -11,35 +11,16 @@ repository.workspace = true [dependencies] async-trait.workspace = true -bytes.workspace = true dialoguer.workspace = true -form_urlencoded.workspace = true -futures.workspace = true -futures-util.workspace = true -http.workspace = true -http-body-util.workspace = true -hyper = { workspace = true, features = ["full"] } -hyper-util = { workspace = true, features = ["full"] } inventory.workspace = true -open.workspace = true openstack-sdk-auth-core.workspace = true +openstack-sdk-websso-host.workspace = true secrecy.workspace = true serde.workspace = true serde_json.workspace = true -serde_urlencoded.workspace = true reqwest = { workspace = true, features = ["form"] } thiserror.workspace = true -tokio = { workspace = true, features = ["signal"] } -tokio-util.workspace = true -tracing.workspace = true url.workspace = true -[dev-dependencies] -httpmock.workspace = true -reserve-port.workspace = true -tempfile.workspace = true -tracing-test.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } - [lints] workspace = true diff --git a/sdk/auth-websso/src/lib.rs b/sdk/auth-websso/src/lib.rs index 3a719a103..f36d36035 100644 --- a/sdk/auth-websso/src/lib.rs +++ b/sdk/auth-websso/src/lib.rs @@ -25,26 +25,12 @@ //! 4. Receive the Keystone token via the callback server //! 5. Return the authenticated token -use std::collections::HashMap; -use std::convert::Infallible; -use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; -use bytes::Bytes; -use futures::io::Error as IoError; -use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Method, Request, Response, StatusCode, body::Incoming as IncomingBody}; -use hyper_util::rt::TokioIo; use secrecy::{ExposeSecret, SecretString}; use serde_json::{Value, json}; use thiserror::Error; -use tokio::net::TcpListener; -use tokio::signal; -use tokio_util::sync::CancellationToken; -use tracing::{error, info, trace, warn}; use url::Url; use dialoguer::Confirm; @@ -53,6 +39,7 @@ use openstack_sdk_auth_core::{ Auth, AuthError, AuthPluginRegistration, AuthToken, AuthTokenError, AuthTokenScope, OpenStackAuthType, }; +use openstack_sdk_websso_host::{BrowserOpenPolicy, CallbackServer, WebssoHostError}; /// WebSSO authentication for OpenStack SDK. /// @@ -154,52 +141,19 @@ pub enum WebSsoError { source: dialoguer::Error, }, - /// Http error. - #[error("http server error: {}", source)] - Http { - /// The source of the error. - #[from] - source: http::Error, - }, - - /// Hyper error. - #[error("hyper (http server) error: {}", source)] - Hyper { - /// The source of the error. - #[from] - source: hyper::Error, - }, - - /// IO communication error. - #[error("`IO` error: {}", source)] - IO { + /// Error from the shared WebSSO callback host service (binding the + /// callback listener, serving/validating the callback, or opening the + /// browser). + #[error("WebSSO host service error: {}", source)] + Host { /// The error source. #[from] - source: IoError, + source: WebssoHostError, }, - /// Thread join error. - #[error("`Join` error: {}", source)] - Join { - /// The error source. - #[from] - source: tokio::task::JoinError, - }, - - /// Auth data is missing. - #[error("Auth data is missing")] - MissingAuthData, - /// Protocol is missing. #[error("Federation protocol information is missing")] MissingProtocol, - - /// Poisoned guard lock in the internal processing. - #[error("internal error: poisoned lock: {}", context)] - PoisonedLock { - /// The source of the error. - context: String, - }, } impl From for AuthError { @@ -221,275 +175,32 @@ pub async fn get_token_auth( // Perform WebSSO by opening a browser window with tiny webserver started to capture the callback /// -/// - start callback server +/// - bind the callback server (host-generated anti-CSRF `state` embedded in +/// its URL) /// - open browser pointing to the SSO url /// - wait for the response with the OpenStack token async fn get_token(url: &mut Url, callback_port: Option) -> Result { - let port = callback_port.unwrap_or(8050); - let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?; - let addr = listener - .local_addr() - .map_err(|_| WebSsoError::MissingAuthData)?; - url.set_query(Some( - format!("origin=http://localhost:{}/callback", addr.port()).as_str(), - )); + let server = CallbackServer::bind(callback_port).await?; + url.set_query(Some(format!("origin={}", server.callback_url()).as_str())); let confirmation = Confirm::new() .with_prompt(format!( "A default browser is going to be opened at `{}`. Do you want to continue?", url.as_str() )) .interact()?; - if confirmation { - info!("Opening browser at {:?}", url.as_str()); - let cancel_token = CancellationToken::new(); - let state: Arc>> = Arc::new(Mutex::new(None)); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - info!("received Ctrl-C, shutting down"); - cancel_token.cancel(); - } - } - }); - - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server(listener, state, cancel_token, None).await } - }); - open::that(url.as_str())?; - - let _res = websso_handle.await?; - - let guard = state.lock().map_err(|_| WebSsoError::PoisonedLock { - context: "locking WebSSO authentication state".to_string(), - })?; - guard.clone().ok_or(WebSsoError::CallbackNoToken) - } else { - Err(WebSsoError::CallbackFailed) - } -} - -/// Start the WebSSO callback server on a pre-bound listener -async fn websso_callback_server( - listener: TcpListener, - state: Arc>>, - cancel_token: CancellationToken, - start_tx: Option>, -) -> Result<(), WebSsoError> { - let addr = listener - .local_addr() - .map_err(|_| WebSsoError::MissingAuthData)?; - info!("Starting webserver to receive SSO callback on {}", addr); - if let Some(tx) = start_tx { - let _ = tx.send(()); - } - // Wait maximum 2 minute for auth processing - let webserver_timeout = Duration::from_secs(120); - loop { - let state_clone = state.clone(); - - tokio::select! { - Ok((stream, _addr)) = listener.accept() => { - let io = TokioIo::new(stream); - let cancel_token_srv = cancel_token.clone(); - let cancel_token_conn = cancel_token.clone(); - - let service = service_fn(move |req| { - let state_clone = state_clone.clone(); - let cancel_token = cancel_token_srv.clone(); - handle_request(req, state_clone, cancel_token) - }); - - tokio::task::spawn(async move { - let cancel_token = cancel_token_conn.clone(); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - error!("Failed to serve connection: {:?}", err); - cancel_token.cancel(); - } - }); - }, - _ = cancel_token.cancelled() => { - info!("Stopping webserver"); - break; - }, - _ = tokio::time::sleep(webserver_timeout) => { - warn!("Timeout of {} sec waiting for authentication expired. Shutting down", webserver_timeout.as_secs()); - cancel_token.cancel(); - } - } - } - Ok(()) -} - -/// Server request handler function -async fn handle_request( - req: Request, - state: Arc>>, - cancel_token: CancellationToken, - //) -> Result>, hyper::Error> { -) -> Result>, WebSsoError> { - match (req.method(), req.uri().path()) { - (&Method::POST, "/callback") => { - let b = req.collect().await?.to_bytes(); - trace!("Body is {:?}", b); - let params = form_urlencoded::parse(b.as_ref()) - .into_owned() - .collect::>(); - trace!("Params = {:?}", params); - - let mut data = state.lock().map_err(|_| WebSsoError::PoisonedLock { - context: "locking WebSSO authentication state".to_string(), - })?; - if let Some(token) = params.get("token") { - *data = Some(token.clone()); - } - cancel_token.cancel(); - - Ok(Response::builder() - .body(Full::new(Bytes::from(include_str!("../static/callback.html"))).boxed())?) - } - _ => { - // Return 404 not found response. - Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Empty::::new().boxed())?) - } - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - use tokio::net::TcpListener; - use tokio::signal; - use tokio_util::sync::CancellationToken; - use tracing::{info, warn}; - - use super::WebSsoError; - use super::handle_request; - - /// Test-only variant that accepts a pre-bound listener to avoid port reservation races - async fn websso_callback_server_test( - listener: TcpListener, - state: Arc>>, - cancel_token: CancellationToken, - ) -> Result<(), WebSsoError> { - use hyper::server::conn::http1; - use hyper::service::service_fn; - - use hyper_util::rt::TokioIo; - use tracing::error; - - info!("Starting webserver to receive SSO callback"); - let webserver_timeout = std::time::Duration::from_secs(120); - loop { - let state_clone = state.clone(); - - tokio::select! { - Ok((stream, _addr)) = listener.accept() => { - let io = TokioIo::new(stream); - let cancel_token_srv = cancel_token.clone(); - let cancel_token_conn = cancel_token.clone(); - - let service = service_fn(move |req| { - let state_clone = state_clone.clone(); - let cancel_token = cancel_token_srv.clone(); - handle_request(req, state_clone, cancel_token) - }); - - tokio::task::spawn(async move { - let cancel_token = cancel_token_conn.clone(); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - error!("Failed to serve connection: {:?}", err); - cancel_token.cancel(); - } - }); - }, - _ = cancel_token.cancelled() => { - info!("Stopping webserver"); - break; - }, - _ = tokio::time::sleep(webserver_timeout) => { - warn!("Timeout of {} sec waiting for authentication expired. Shutting down", webserver_timeout.as_secs()); - cancel_token.cancel(); - } - } - } - Ok(()) - } - - #[tokio::test] - async fn test_callback() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("port available"); - let addr = listener.local_addr().expect("listener address"); - let cancel_token = CancellationToken::new(); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - cancel_token.cancel(); - } - } - }); - - let state = Arc::new(Mutex::new(None)); - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server_test(listener, state, cancel_token).await } - }); - - let params = [("token", "foo_bar_baz")]; - let client = reqwest::Client::new(); - client - .post(format!("http://localhost:{}/callback", addr.port())) - .form(¶ms) - .send() - .await - .unwrap(); - - websso_handle.await.unwrap().unwrap(); - assert_eq!(*state.lock().unwrap(), Some(params[0].1.into())); - } - - #[tokio::test] - async fn test_callback_no_token() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("port available"); - let addr = listener.local_addr().expect("listener address"); - let cancel_token = CancellationToken::new(); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - cancel_token.cancel(); - } - } - }); - - let state = Arc::new(Mutex::new(None)); - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server_test(listener, state, cancel_token).await } - }); - - let client = reqwest::Client::new(); - client - .post(format!("http://localhost:{}/callback", addr.port())) - .send() - .await - .unwrap(); - - websso_handle.await.unwrap().unwrap(); - assert_eq!(*state.lock().unwrap(), None); + if !confirmation { + return Err(WebSsoError::CallbackFailed); } + // `require_https: false`: the Keystone identity endpoint this URL is + // built from may legitimately be plain `http://` in a local/dev + // deployment, unlike the WASM SSO plugin ABI which has no such + // grandfathered use case and always requires `https://`. + openstack_sdk_websso_host::open_browser( + url, + BrowserOpenPolicy { + require_https: false, + }, + )?; + let mut params = server.wait_for_callback(Duration::from_secs(120)).await?; + params.remove("token").ok_or(WebSsoError::CallbackNoToken) } diff --git a/sdk/plugin-wasm/Cargo.toml b/sdk/plugin-wasm/Cargo.toml index e86ecb6fa..5ba5798fc 100644 --- a/sdk/plugin-wasm/Cargo.toml +++ b/sdk/plugin-wasm/Cargo.toml @@ -13,12 +13,17 @@ repository.workspace = true [dependencies] async-trait.workspace = true +base64.workspace = true chrono.workspace = true +dialoguer.workspace = true dirs.workspace = true extism.workspace = true openstack-sdk-auth-core.workspace = true -reqwest = { workspace = true, features = ["rustls", "blocking"] } +openstack-sdk-websso-host.workspace = true +reqwest = { workspace = true, features = ["rustls", "blocking", "json"] } +ring.workspace = true secrecy.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true @@ -26,11 +31,17 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "sync"] } tracing.workspace = true url.workspace = true +x509-parser = { workspace = true, features = ["verify"] } [dev-dependencies] httpmock.workspace = true +rcgen.workspace = true +reqwest = { workspace = true, features = ["form"] } tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +[features] +fuzzing = [] + [lints] workspace = true diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock new file mode 100644 index 000000000..f06fa979c --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock @@ -0,0 +1,663 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "example-wasm-sso-plugin" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "serde_json", + "url", +] + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", +] + +[[package]] +name = "extism-convert-macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" +dependencies = [ + "manyhow", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +dependencies = [ + "anyhow", + "base64", + "extism-convert", + "extism-manifest", + "extism-pdk-derive", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml new file mode 100644 index 000000000..8fe9e399f --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "example-wasm-sso-plugin" +description = "Example SSO ABI v1 plugin used by openstack-sdk-plugin-wasm's tests" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +extism-pdk = "1.4" +serde_json = "1" +url = "2" diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs b/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs new file mode 100644 index 000000000..d333ad930 --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs @@ -0,0 +1,115 @@ +#![no_main] + +//! Example SSO ABI v1 plugin, used as a test fixture by +//! `openstack-sdk-plugin-wasm`'s integration tests. It implements a toy +//! browser-based `v3examplesso` method: `sso_build_request` points the +//! browser at a fake identity-provider authorize page carrying the +//! host-provided callback URL as its `redirect_uri`, and +//! `sso_parse_callback` reads the `token` field out of whatever the +//! callback POST carried. +//! +//! Neither export performs any I/O — the guest sandbox forbids sockets +//! entirely (`Manifest::disallow_all_hosts`), so this is enforced +//! structurally, not just by convention. +//! +//! A `mode` value (`values.mode`) lets the same fixture also exercise the +//! host's SSO security checks: +//! - `"bad_scheme"` — returns a plain `http://` url (must be rejected before +//! any browser is opened). +//! - `"bad_host"` — returns a `redirect_host` that doesn't match the +//! callback URL the host handed in (must be rejected before any browser +//! is opened). +//! - anything else (including absent) — well-behaved. + +use extism_pdk::*; +use serde_json::{Value, json}; + +#[plugin_fn] +pub fn plugin_abi_version(_input: String) -> FnResult { + Ok("1".to_string()) +} + +#[plugin_fn] +pub fn auth_supported_methods(_input: String) -> FnResult { + Ok(json!(["v3examplesso"]).to_string()) +} + +#[plugin_fn] +pub fn auth_api_version(_input: String) -> FnResult { + Ok(json!([3, 0]).to_string()) +} + +#[plugin_fn] +pub fn auth_requirements(_input: String) -> FnResult { + Ok(json!({ + "type": "object", + "properties": {} + }) + .to_string()) +} + +#[plugin_fn] +pub fn sso_build_request(input: String) -> FnResult { + let request: Value = serde_json::from_str(&input)?; + let callback_url = request + .get("callback_url") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let mode = request + .get("values") + .and_then(|v| v.get("mode")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let parsed = url::Url::parse(callback_url) + .map_err(|e| Error::msg(format!("invalid callback_url: {e}")))?; + let real_redirect_host = match parsed.port() { + Some(port) => format!("{}:{port}", parsed.host_str().unwrap_or("")), + None => parsed.host_str().unwrap_or("").to_string(), + }; + + let (scheme, redirect_host) = match mode { + "bad_scheme" => ("http", real_redirect_host.as_str()), + "bad_host" => ("https", "evil.example.test:1"), + _ => ("https", real_redirect_host.as_str()), + }; + + let url = format!( + "{scheme}://idp.example.test/authorize?client_id=demo&redirect_uri={}", + urlencode(callback_url) + ); + + Ok(json!({"url": url, "redirect_host": redirect_host}).to_string()) +} + +#[plugin_fn] +pub fn sso_parse_callback(input: String) -> FnResult { + let request: Value = serde_json::from_str(&input)?; + let token = request + .get("params") + .and_then(|p| p.get("token")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + if token.is_empty() { + return Ok(json!({"error": "callback didn't carry a token"}).to_string()); + } + + Ok(json!({"ok": {"token": token, "auth_info": null}}).to_string()) +} + +/// Minimal query-value percent-encoding, just enough for the test fixture's +/// own callback URL (which itself only ever contains `[A-Za-z0-9:/._-]` plus +/// `?`, `=`, `&`) — not a general-purpose encoder. +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} diff --git a/sdk/plugin-wasm/src/error.rs b/sdk/plugin-wasm/src/error.rs index abf444743..f09ec7fef 100644 --- a/sdk/plugin-wasm/src/error.rs +++ b/sdk/plugin-wasm/src/error.rs @@ -157,4 +157,131 @@ pub enum WasmPluginError { /// versions of `name`". version: Option, }, + + /// The registry index could not be fetched. + #[error("failed to fetch plugin registry index from {url}: {source}")] + RegistryFetch { + /// Registry index URL. + url: String, + /// The underlying HTTP error. + #[source] + source: reqwest::Error, + }, + + /// The registry index was fetched but could not be parsed, or declared + /// an unsupported `schema_version`. + #[error("malformed plugin registry index at {url}: {reason}")] + RegistryFormat { + /// Registry index URL. + url: String, + /// Human readable reason. + reason: String, + }, + + /// A downloaded plugin's content does not match the sha256 declared for + /// it in the registry index. + #[error( + "downloaded plugin {name}@{version} failed checksum verification: expected sha256 {expected}, got {actual}" + )] + ChecksumMismatch { + /// Plugin name. + name: String, + /// Plugin version. + version: String, + /// The sha256 declared in the registry index. + expected: String, + /// The sha256 computed from the downloaded bytes. + actual: String, + }, + + /// The requested `name`/`version` is not present in the registry index. + #[error("no plugin matches {name}{} in the registry index", version.as_ref().map(|v| format!("@{v}")).unwrap_or_default())] + NotInIndex { + /// Plugin name. + name: String, + /// Specific version requested, if any. + version: Option, + }, + + /// Install/update was refused because the plugin's provenance could not + /// be verified and `--allow-unsigned` was not given. + #[error( + "refusing to install {name}@{version} without provenance verification: {reason} (pass --allow-unsigned to override)" + )] + Untrusted { + /// Plugin name. + name: String, + /// Plugin version. + version: String, + /// Human readable reason verification did not succeed. + reason: String, + }, + + /// The GitHub attestations API could not be reached or returned an + /// unexpected response. + #[error("failed to fetch attestations for {owner}/{repo}: {source}")] + AttestationFetch { + /// Repository owner. + owner: String, + /// Repository name. + repo: String, + /// The underlying HTTP error. + #[source] + source: reqwest::Error, + }, + + /// A fetched attestation bundle failed cryptographic or identity + /// verification. + #[error("attestation verification failed: {reason}")] + AttestationVerification { + /// Human readable reason. + reason: String, + }, + + /// The shared WebSSO host service (callback listener, CSRF check, or + /// browser-opening step) reported an error while running the SSO ABI + /// flow. + #[error("plugin {name} SSO flow failed: {source}")] + Host { + /// Plugin name. + name: String, + /// The underlying host-service error. + #[source] + source: openstack_sdk_websso_host::WebssoHostError, + }, + + /// Error using the interactive confirmation prompt during the SSO flow. + #[error("error using the dialoguer: {}", source)] + Dialoguer { + /// The error source. + #[from] + source: dialoguer::Error, + }, + + /// `sso_build_request` returned a URL that failed host-side validation + /// (unparsable, or not `https://`). + #[error("plugin {name} `sso_build_request` returned an invalid redirect: {reason}")] + InvalidRedirect { + /// Plugin name. + name: String, + /// Human readable reason. + reason: String, + }, + + /// `sso_build_request`'s declared `redirect_host` didn't match the + /// host-bound callback listener's own authority — the plugin tried to + /// point the identity provider's redirect somewhere the host never + /// bound a listener on. Always rejected; there is no override. + #[error( + "plugin {name} declared SSO redirect host `{declared}` but the host-bound callback listener is `{expected}`; refusing to open the browser" + )] + RedirectHostMismatch { + /// Plugin name. + name: String, + /// The `redirect_host` the plugin's `sso_build_request` response + /// declared. + declared: String, + /// The actual authority of the host-bound callback listener. + expected: String, + }, } diff --git a/sdk/plugin-wasm/src/host.rs b/sdk/plugin-wasm/src/host.rs index c7fc5c1c1..caf2d2bb1 100644 --- a/sdk/plugin-wasm/src/host.rs +++ b/sdk/plugin-wasm/src/host.rs @@ -56,15 +56,46 @@ struct HttpResponseMsg { body: String, } -extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request: String) -> String { - let req: HttpRequestMsg = serde_json::from_str(&request) - .map_err(|e| extism::Error::msg(format!("invalid identity_http_request payload: {e}")))?; - +/// Validate a guest-supplied request against `origin` and turn it into a +/// concrete URL + method, without performing any I/O. This is the entire +/// part of `identity_http_request`'s handling of untrusted guest bytes that +/// doesn't require a live [`HostContextState`], split out so it can be +/// exercised directly (including by the `fuzzing`-feature entry point below) +/// without a real WASM call or network access. +fn resolve_request( + origin: &url::Url, + req: &HttpRequestMsg, +) -> Result<(url::Url, reqwest::Method), extism::Error> { if !req.path.starts_with('/') { return Err(extism::Error::msg( "identity_http_request: `path` must be relative to the identity endpoint (start with '/')", )); } + let url = origin + .join(&req.path) + .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid path: {e}")))?; + let method = reqwest::Method::from_bytes(req.method.as_bytes()) + .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid method: {e}")))?; + Ok((url, method)) +} + +/// Fuzz target entry point for the otherwise-private [`resolve_request`], +/// the part of `identity_http_request` that parses and validates raw bytes +/// a guest module controls (Extism's guest-to-host call boundary), without +/// making any network call. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_identity_http_request_parsing(origin: &url::Url, request: &str) { + if let Ok(req) = serde_json::from_str::(request) { + let _ = resolve_request(origin, &req); + } +} + +extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request: String) -> String { + let req: HttpRequestMsg = serde_json::from_str(&request) + .map_err(|e| extism::Error::msg(format!("invalid identity_http_request payload: {e}")))?; let state = ctx.get()?; let state = state @@ -79,11 +110,7 @@ extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request .as_ref() .ok_or_else(|| extism::Error::msg("identity_http_request: no http client bound to this call"))?; - let url = origin - .join(&req.path) - .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid path: {e}")))?; - let method = reqwest::Method::from_bytes(req.method.as_bytes()) - .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid method: {e}")))?; + let (url, method) = resolve_request(origin, &req)?; let mut builder = client.request(method, url); for (k, v) in &req.headers { diff --git a/sdk/plugin-wasm/src/index.rs b/sdk/plugin-wasm/src/index.rs new file mode 100644 index 000000000..5f0413369 --- /dev/null +++ b/sdk/plugin-wasm/src/index.rs @@ -0,0 +1,405 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Client for the HTTPS-hosted plugin registry index (`index.json`), and the +//! download step that turns a resolved [`IndexVersion`] into checksummed +//! bytes ready for [`crate::registry::plan_remote_install`]. +//! +//! The default index lives in this repository at +//! `plugins/registry/index.json`, served over plain HTTPS via +//! `raw.githubusercontent.com`. Nothing about the schema or this client is +//! tied to that location: any URL matching the schema documented in +//! `plugins/registry/README.md` works, and `--registry-url` on the CLI lets +//! a different one be used explicitly. The default is never silently +//! overridden by an environment variable — only an explicit flag changes it, +//! so a plugin never ends up resolved against an unexpected index by +//! accident. + +use std::time::Duration; + +use serde::Deserialize; +use url::Url; + +use crate::error::WasmPluginError; + +/// The registry index consulted when no `--registry-url` is given. +pub const DEFAULT_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/gtema/openstack/main/plugins/registry/index.json"; + +/// The only `schema_version` this client understands. +const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// The full registry index. +#[derive(Clone, Debug, Deserialize)] +pub struct PluginIndex { + /// Schema version of this index document. + pub schema_version: u32, + /// Published plugins. + #[serde(default)] + pub plugins: Vec, +} + +/// A single plugin's registry entry: its name and every version published +/// for it. +#[derive(Clone, Debug, Deserialize)] +pub struct IndexEntry { + /// Plugin name. + pub name: String, + /// Human readable one-line description. + #[serde(default)] + pub description: String, + /// Published versions. + pub versions: Vec, +} + +/// A single published version of a plugin. +#[derive(Clone, Debug, Deserialize)] +pub struct IndexVersion { + /// Version string. Compared as [`semver::Version`] when possible for + /// "latest" resolution, falling back to exact string match otherwise. + pub version: String, + /// URL the `.wasm` artifact is downloaded from. + pub download_url: Url, + /// Lowercase hex-encoded sha256 the downloaded bytes must match before + /// anything is written to disk. + pub sha256: String, + /// The `owner/repo` GitHub repository whose CI is expected to have + /// published (and attested) this artifact. + pub source_repo: String, + /// Informational: the guest ABI version this build declares. + #[serde(default)] + pub abi_version: Option, + /// Minimum `osc` version required to install this version, if any. + #[serde(default)] + pub min_cli_version: Option, +} + +/// Build a `reqwest::Client` suitable for registry/attestation fetches: +/// rustls, a bounded timeout, and an identifying user agent. Shared by +/// [`fetch_index`]/[`download`] and `crate::provenance`. +pub fn http_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .user_agent(concat!("osc-plugin-manager/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|source| WasmPluginError::RegistryFetch { + url: "".into(), + source, + }) +} + +/// Fetch and parse the registry index at `url`. +pub async fn fetch_index( + url: &str, + client: &reqwest::Client, +) -> Result { + let response = client + .get(url) + .send() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })? + .error_for_status() + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })?; + let bytes = response + .bytes() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })?; + let index: PluginIndex = + serde_json::from_slice(&bytes).map_err(|e| WasmPluginError::RegistryFormat { + url: url.to_string(), + reason: e.to_string(), + })?; + if index.schema_version != SUPPORTED_SCHEMA_VERSION { + return Err(WasmPluginError::RegistryFormat { + url: url.to_string(), + reason: format!( + "unsupported schema_version {} (expected {SUPPORTED_SCHEMA_VERSION})", + index.schema_version + ), + }); + } + Ok(index) +} + +/// Entries whose name or description contains `query` (case-insensitive), +/// or every entry when `query` is `None`. +pub fn search<'a>(index: &'a PluginIndex, query: Option<&str>) -> Vec<&'a IndexEntry> { + match query { + None => index.plugins.iter().collect(), + Some(q) => { + let q = q.to_lowercase(); + index + .plugins + .iter() + .filter(|e| { + e.name.to_lowercase().contains(&q) || e.description.to_lowercase().contains(&q) + }) + .collect() + } + } +} + +/// Resolve `version` (or, when `None`, the highest by semver, falling back +/// to the lexicographically greatest version string when any published +/// version doesn't parse as semver) within `entry`. +pub fn resolve_version<'a>( + entry: &'a IndexEntry, + version: Option<&str>, +) -> Result<&'a IndexVersion, WasmPluginError> { + match version { + Some(v) => entry + .versions + .iter() + .find(|iv| iv.version == v) + .ok_or_else(|| WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: Some(v.to_string()), + }), + None => { + if entry.versions.is_empty() { + return Err(WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: None, + }); + } + let all_semver: Option> = entry + .versions + .iter() + .map(|iv| semver::Version::parse(&iv.version).ok().map(|sv| (sv, iv))) + .collect(); + let latest = match all_semver { + Some(mut parsed) => { + parsed.sort_by(|a, b| a.0.cmp(&b.0)); + parsed.pop().map(|(_, iv)| iv) + } + None => entry + .versions + .iter() + .max_by(|a, b| a.version.cmp(&b.version)), + }; + latest.ok_or_else(|| WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: None, + }) + } + } +} + +/// Download `version`'s artifact and verify its sha256 matches what the +/// index declared, before returning the bytes to the caller. Nothing is +/// written to disk here or by any caller before this check passes. +pub async fn download( + entry_name: &str, + version: &IndexVersion, + client: &reqwest::Client, +) -> Result, WasmPluginError> { + let response = client + .get(version.download_url.clone()) + .send() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })? + .error_for_status() + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })?; + let bytes = response + .bytes() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })? + .to_vec(); + + let actual = sha256_hex_bytes(&bytes); + if actual != version.sha256.to_lowercase() { + return Err(WasmPluginError::ChecksumMismatch { + name: entry_name.to_string(), + version: version.version.clone(), + expected: version.sha256.clone(), + actual, + }); + } + Ok(bytes) +} + +/// Lowercase hex-encoded sha256 of an in-memory byte slice. Mirrors +/// [`crate::lockfile::sha256_hex`]'s streaming file-based variant for bytes +/// that are already in memory (downloaded content, not yet written to disk). +fn sha256_hex_bytes(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_index() -> PluginIndex { + PluginIndex { + schema_version: 1, + plugins: vec![IndexEntry { + name: "example_auth".into(), + description: "Example auth plugin".into(), + versions: vec![ + IndexVersion { + version: "1.0.0".into(), + download_url: "https://example.invalid/v1.wasm".parse().unwrap(), + sha256: "aaaa".into(), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: Some("1".into()), + min_cli_version: None, + }, + IndexVersion { + version: "1.2.0".into(), + download_url: "https://example.invalid/v1.2.wasm".parse().unwrap(), + sha256: "bbbb".into(), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: Some("1".into()), + min_cli_version: None, + }, + ], + }], + } + } + + #[test] + fn search_matches_name_and_description_case_insensitively() { + let index = sample_index(); + assert_eq!(search(&index, None).len(), 1); + assert_eq!(search(&index, Some("EXAMPLE")).len(), 1); + assert_eq!(search(&index, Some("nope")).len(), 0); + } + + #[test] + fn resolve_version_picks_highest_semver_by_default() -> Result<(), Box> { + let index = sample_index(); + let entry = &index.plugins[0]; + let latest = resolve_version(entry, None)?; + assert_eq!(latest.version, "1.2.0"); + let exact = resolve_version(entry, Some("1.0.0"))?; + assert_eq!(exact.sha256, "aaaa"); + assert!(resolve_version(entry, Some("9.9.9")).is_err()); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_index_parses_a_served_document() -> Result<(), Box> { + let server = httpmock::MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200).json_body(serde_json::json!({ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Example auth plugin", + "versions": [ + { + "version": "1.0.0", + "download_url": "https://example.invalid/v1.wasm", + "sha256": "aaaa", + "source_repo": "gtema/example-auth-plugin" + } + ] + } + ] + })); + }); + let client = http_client()?; + let index = fetch_index(&format!("{}/index.json", server.base_url()), &client).await?; + mock.assert(); + assert_eq!(index.plugins.len(), 1); + assert_eq!(index.plugins[0].name, "example_auth"); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_index_rejects_unsupported_schema_version() { + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200) + .json_body(serde_json::json!({"schema_version": 2, "plugins": []})); + }); + let client = http_client().expect("client builds"); + let result = fetch_index(&format!("{}/index.json", server.base_url()), &client).await; + assert!(matches!( + result, + Err(WasmPluginError::RegistryFormat { .. }) + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn download_rejects_checksum_mismatch_before_any_caller_sees_the_bytes() + -> Result<(), Box> { + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/plugin.wasm"); + then.status(200).body(b"not what the index promised"); + }); + let iv = IndexVersion { + version: "1.0.0".into(), + download_url: format!("{}/plugin.wasm", server.base_url()).parse()?, + sha256: "0".repeat(64), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: None, + min_cli_version: None, + }; + let client = http_client()?; + let result = download("example_auth", &iv, &client).await; + assert!(matches!( + result, + Err(WasmPluginError::ChecksumMismatch { .. }) + )); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn download_returns_bytes_matching_the_declared_checksum() + -> Result<(), Box> { + let payload = b"a fake but checksum-consistent wasm module"; + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/plugin.wasm"); + then.status(200).body(payload.as_slice()); + }); + let iv = IndexVersion { + version: "1.0.0".into(), + download_url: format!("{}/plugin.wasm", server.base_url()).parse()?, + sha256: sha256_hex_bytes(payload), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: None, + min_cli_version: None, + }; + let client = http_client()?; + let bytes = download("example_auth", &iv, &client).await?; + assert_eq!(bytes, payload); + Ok(()) + } +} diff --git a/sdk/plugin-wasm/src/lib.rs b/sdk/plugin-wasm/src/lib.rs index e7c7729aa..30b8818a9 100644 --- a/sdk/plugin-wasm/src/lib.rs +++ b/sdk/plugin-wasm/src/lib.rs @@ -20,10 +20,19 @@ pub mod error; pub(crate) mod host; +pub mod index; pub mod lockfile; pub mod plugin; +pub mod provenance; pub mod registry; pub use error::WasmPluginError; pub use lockfile::{PluginEntry, PluginLockfile, TrustInfo}; pub use plugin::WasmAuthPlugin; + +/// Fuzz target entry point for the otherwise-private [`host`] module. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub use host::fuzz_identity_http_request_parsing; diff --git a/sdk/plugin-wasm/src/lockfile.rs b/sdk/plugin-wasm/src/lockfile.rs index 6636ab0d9..e89f0a9d9 100644 --- a/sdk/plugin-wasm/src/lockfile.rs +++ b/sdk/plugin-wasm/src/lockfile.rs @@ -47,6 +47,32 @@ pub struct TrustInfo { pub allow_unsigned: bool, } +/// The result of verifying a plugin's GitHub artifact attestation, recorded +/// on successful install/update via `crate::provenance::verify_attestation`. +/// +/// Presence of this field does not by itself mean the plugin is trusted: +/// what mattered is whether verification *succeeded* at install/update time, +/// which is what gated whether the install was allowed to proceed without +/// `--allow-unsigned` in the first place. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProvenanceRecord { + /// The `owner/repo` the attestation's signing identity was verified + /// against. + pub source_repo: String, + /// The GitHub Actions workflow ref (from the signing certificate's SAN), + /// if present. + pub workflow_ref: Option, + /// The OIDC issuer the signing certificate was issued for (expected + /// `https://token.actions.githubusercontent.com`). + pub oidc_issuer: Option, + /// A Rekor transparency-log entry index found in the attestation + /// bundle, if any. **Not** itself verified for inclusion (no Merkle + /// audit-path check is performed) — informational only. + pub rekor_log_index: Option, + /// When this verification was performed. + pub verified_at: DateTime, +} + /// A single installed `name@version` plugin record. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct PluginEntry { @@ -64,6 +90,16 @@ pub struct PluginEntry { pub installed_at: DateTime, /// Trust metadata for this entry. pub trust: TrustInfo, + /// Whether this entry was installed with an explicit `@version` (`true`) + /// or resolved to "latest" (`false`). `osc plugin update --all` skips + /// pinned entries. + #[serde(default)] + pub pinned: bool, + /// The provenance verification result recorded at install/update time, + /// if any was performed (registry installs only; local `--file` installs + /// have no provenance source to check). + #[serde(default)] + pub provenance: Option, } /// The full set of installed plugins and which version of each is active. @@ -221,6 +257,8 @@ mod tests { confirmed_by_user: true, allow_unsigned: true, }, + pinned: false, + provenance: None, }, ); lf.active.insert("demo".into(), "1.0.0".into()); diff --git a/sdk/plugin-wasm/src/plugin.rs b/sdk/plugin-wasm/src/plugin.rs index 3e9d4517a..14f166fde 100644 --- a/sdk/plugin-wasm/src/plugin.rs +++ b/sdk/plugin-wasm/src/plugin.rs @@ -18,7 +18,7 @@ //! //! ## Guest ABI (version 1) //! -//! A conforming module exports: +//! Every conforming module exports: //! //! - `plugin_abi_version(_: string) -> string` — must return the literal `"1"`. //! - `auth_supported_methods(_: string) -> string` — JSON array of auth method @@ -27,14 +27,45 @@ //! - `auth_requirements(hints: string) -> string` — `hints` is a JSON value or //! the literal `null`; returns a JSON Schema object describing required //! fields, in the same shape [`OpenStackAuthType::requirements`] expects. +//! +//! and then exactly one of the two ABI flavors below, detected at load time +//! via `extism::Plugin::function_exists`: +//! +//! ### `auth` flavor +//! //! - `auth(request: string) -> string` — `request` is a JSON object //! `{"identity_url", "values", "scope", "hints"}`; returns either //! `{"ok": {"token": "...", "auth_info": }}` or //! `{"error": "human readable message"}`. //! -//! Only `auth` may perform outbound HTTP, and only via the host-provided -//! `identity_http_request` import — never directly. Every other export must be -//! a pure computation over its input. +//! Only `auth` may perform outbound HTTP, and only via the host-provided +//! `identity_http_request` import — never directly. +//! +//! ### `sso` flavor +//! +//! For interactive, browser-based (WebSSO-style) plugins. Neither export may +//! perform any I/O: the host owns the callback listener, the anti-CSRF +//! `state` check, and the browser-opening step (via +//! `openstack_sdk_websso_host`); the guest only ever sees already-validated +//! strings in and returns strings out. +//! +//! - `sso_build_request(request: string) -> string` — `request` is +//! `{"identity_url", "callback_url", "values", "scope", "hints"}`, where +//! `callback_url` is the host-bound local callback URL (with the +//! anti-CSRF `state` token already embedded) the guest must have the +//! identity provider redirect back to. Returns +//! `{"url": "https://...", "redirect_host": "host:port"}`: `url` is the +//! page to open in the user's browser, and `redirect_host` is the +//! `host:port` authority the guest configured as the identity provider's +//! redirect target. The host verifies, before opening any browser, that +//! `url`'s scheme is `https` and that `redirect_host` exactly matches the +//! host-bound callback listener's own authority — a plugin that returns a +//! URL on a different (undeclared) redirect host is rejected outright, +//! with no override. +//! - `sso_parse_callback(callback: string) -> string` — `callback` is +//! `{"params": {...}}`, the form fields from the already +//! state-validated callback POST; returns the same +//! `{"ok": ...} | {"error": ...}` shape as `auth`. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -42,6 +73,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; +use dialoguer::Confirm; use extism::{Function, Manifest, Plugin, UserData, ValType, Wasm}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; @@ -49,10 +81,22 @@ use serde::{Deserialize, Serialize}; use openstack_sdk_auth_core::{ Auth, AuthError, AuthResponse, AuthToken, AuthTokenScope, OpenStackAuthType, }; +use openstack_sdk_websso_host::{BrowserOpenPolicy, CallbackServer}; use crate::error::WasmPluginError; use crate::host::{self, HostContextState}; +/// A guest module implements exactly one of these two ABI flavors, +/// detected at load time. See the module docs for the exports each one +/// requires. +#[derive(Clone, Debug, PartialEq, Eq)] +enum AbiFlavor { + /// Non-interactive: a single `auth` export handles the whole flow. + Auth, + /// Interactive/browser-based: `sso_build_request` + `sso_parse_callback`. + Sso, +} + /// WASM modules are given at most this much linear memory. const DEFAULT_MAX_MEMORY_PAGES: u32 = 256; // 256 * 64KiB = 16MiB /// A single guest call (including any `identity_http_request` round trips it @@ -81,6 +125,26 @@ enum AuthResultMsg { }, } +#[derive(Serialize)] +struct SsoBuildRequestMsg { + identity_url: String, + callback_url: String, + values: BTreeMap, + scope: Option, + hints: Option, +} + +#[derive(Deserialize)] +struct SsoBuildResponseMsg { + url: String, + redirect_host: String, +} + +#[derive(Serialize)] +struct SsoCallbackMsg { + params: BTreeMap, +} + /// A single loaded WASM auth plugin, wrapping an [`extism::Plugin`] instance. /// /// Cheaply cloneable: the underlying plugin and its per-call host state are @@ -96,6 +160,7 @@ pub struct WasmAuthPlugin { host_ctx: UserData, supported_methods: Vec<&'static str>, api_version: (u8, u8), + flavor: AbiFlavor, } impl std::fmt::Debug for WasmAuthPlugin { @@ -105,6 +170,7 @@ impl std::fmt::Debug for WasmAuthPlugin { .field("source", &self.source) .field("supported_methods", &self.supported_methods) .field("api_version", &self.api_version) + .field("flavor", &self.flavor) .finish_non_exhaustive() } } @@ -208,6 +274,26 @@ impl WasmAuthPlugin { reason: format!("auth_api_version did not return a JSON [major, minor] pair: {e}"), })?; + let has_auth = plugin.function_exists("auth"); + let has_sso = plugin.function_exists("sso_build_request") + && plugin.function_exists("sso_parse_callback"); + let flavor = match (has_auth, has_sso) { + (true, false) => AbiFlavor::Auth, + (false, true) => AbiFlavor::Sso, + (true, true) => { + return Err(WasmPluginError::InvalidAbi { + name, + reason: "plugin exports both `auth` and the SSO entry points (`sso_build_request`/`sso_parse_callback`); a module must implement exactly one ABI flavor".into(), + }); + } + (false, false) => { + return Err(WasmPluginError::InvalidAbi { + name, + reason: "plugin exports neither `auth` nor the SSO entry points (`sso_build_request`+`sso_parse_callback`)".into(), + }); + } + }; + Ok(Self { name, source: path.to_path_buf(), @@ -215,6 +301,7 @@ impl WasmAuthPlugin { host_ctx, supported_methods, api_version: (major, minor), + flavor, }) } @@ -282,6 +369,21 @@ impl OpenStackAuthType for WasmAuthPlugin { values: &std::collections::HashMap, scope: Option<&AuthTokenScope>, hints: Option<&serde_json::Value>, + ) -> Result { + match self.flavor { + AbiFlavor::Auth => self.auth_via_auth(identity_url, values, scope, hints).await, + AbiFlavor::Sso => self.auth_via_sso(identity_url, values, scope, hints).await, + } + } +} + +impl WasmAuthPlugin { + async fn auth_via_auth( + &self, + identity_url: &url::Url, + values: &std::collections::HashMap, + scope: Option<&AuthTokenScope>, + hints: Option<&serde_json::Value>, ) -> Result { let request = AuthRequestMsg { identity_url: identity_url.to_string(), @@ -364,4 +466,223 @@ impl OpenStackAuthType for WasmAuthPlugin { }), } } + + /// Run the `sso` ABI flavor: bind a host-owned callback listener, ask + /// the guest (a pure computation) to build the identity-provider URL to + /// open, validate that URL before ever opening a browser, wait for the + /// already state-validated callback, then hand the callback's fields + /// back to the guest (again pure) to turn into a token. + /// + /// The guest never sees a socket, a browser-opening capability, or the + /// raw (unvalidated) callback request — every step it participates in + /// is a JSON-in, JSON-out call over data the host has already checked. + async fn auth_via_sso( + &self, + identity_url: &url::Url, + values: &std::collections::HashMap, + scope: Option<&AuthTokenScope>, + hints: Option<&serde_json::Value>, + ) -> Result { + let callback_port = values + .get("callback_port") + .and_then(|v| v.expose_secret().parse::().ok()); + + let server = CallbackServer::bind(callback_port) + .await + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let request = SsoBuildRequestMsg { + identity_url: identity_url.to_string(), + callback_url: server.callback_url().to_string(), + values: values + .iter() + .map(|(k, v)| (k.clone(), v.expose_secret().to_string())) + .collect(), + scope: scope.map(serde_json::to_value).transpose()?, + hints: hints.cloned(), + }; + let request_json = serde_json::to_string(&request)?; + + let build_output = self + .call_guest("sso_build_request", request_json) + .await + .map_err(AuthError::plugin)?; + + // The plugin's declared redirect target must be exactly the + // callback listener the host itself just bound. A mismatch means + // the plugin is trying to point the identity provider's redirect + // somewhere the host never bound a listener on — always rejected, + // no override. + let sso_url = + validate_sso_build_response(&self.name, &build_output, &server.redirect_host()) + .map_err(AuthError::plugin)?; + + let confirmation = Confirm::new() + .with_prompt(format!( + "A default browser is going to be opened at `{}`. Do you want to continue?", + sso_url.as_str() + )) + .interact() + .map_err(WasmPluginError::from) + .map_err(AuthError::plugin)?; + if !confirmation { + return Err(AuthError::plugin(WasmPluginError::InvalidRedirect { + name: self.name.clone(), + reason: "user declined to open the browser".into(), + })); + } + + openstack_sdk_websso_host::open_browser( + &sso_url, + BrowserOpenPolicy { + require_https: true, + }, + ) + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let params = server + .wait_for_callback(Duration::from_secs(120)) + .await + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let callback = SsoCallbackMsg { + params: params.into_iter().collect(), + }; + let callback_json = serde_json::to_string(&callback)?; + + let output = self + .call_guest("sso_parse_callback", callback_json) + .await + .map_err(AuthError::plugin)?; + + let parsed: AuthResultMsg = serde_json::from_str(&output).map_err(|source| { + AuthError::plugin(WasmPluginError::MalformedAuthResponse { + name: self.name.clone(), + source, + }) + })?; + + match parsed { + AuthResultMsg::Ok { token, auth_info } => { + Ok(Auth::AuthToken(Box::new(AuthToken::new(token, *auth_info)))) + } + AuthResultMsg::Error { error } => Err(AuthError::UnknownAuth { + code: 0, + message: Some(error), + }), + } + } + + /// Call a pure (no host-function-using) guest export off the async + /// runtime, since a WASM call can run for up to the plugin's configured + /// timeout and must not stall the executor. + async fn call_guest( + &self, + function: &'static str, + input: String, + ) -> Result { + let name = self.name.clone(); + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || -> Result { + let mut plugin = inner.lock().map_err(|_| WasmPluginError::HostContext { + name: name.clone(), + function, + reason: "plugin lock poisoned".into(), + })?; + plugin + .call(function, input.as_str()) + .map_err(|source| WasmPluginError::Call { + name, + function, + source, + }) + }) + .await + .map_err(|source| WasmPluginError::Join { + name: self.name.clone(), + function, + source, + })? + } +} + +/// Parse and validate a guest's `sso_build_request` response: the returned +/// `url` must be well-formed and `https`, and the declared `redirect_host` +/// must exactly match `expected_redirect_host` (the host-bound callback +/// listener's own authority). This is the one guest-response deserialization +/// path in this module with security-relevant validation logic beyond a +/// plain type check, so it's kept as a standalone, panic-free function that +/// both [`WasmAuthPlugin::auth_via_sso`] and the `fuzzing`-feature entry +/// point below can exercise directly. +fn validate_sso_build_response( + name: &str, + build_output: &str, + expected_redirect_host: &str, +) -> Result { + let build: SsoBuildResponseMsg = serde_json::from_str(build_output).map_err(|source| { + WasmPluginError::MalformedAuthResponse { + name: name.to_string(), + source, + } + })?; + + let sso_url = + url::Url::parse(&build.url).map_err(|source| WasmPluginError::InvalidRedirect { + name: name.to_string(), + reason: format!("`sso_build_request` returned an unparsable url: {source}"), + })?; + if sso_url.scheme() != "https" { + return Err(WasmPluginError::InvalidRedirect { + name: name.to_string(), + reason: format!( + "`sso_build_request` returned a non-https url (scheme was `{}`)", + sso_url.scheme() + ), + }); + } + if build.redirect_host != expected_redirect_host { + return Err(WasmPluginError::RedirectHostMismatch { + name: name.to_string(), + declared: build.redirect_host, + expected: expected_redirect_host.to_string(), + }); + } + Ok(sso_url) +} + +/// Fuzz target entry point for the otherwise-private +/// [`validate_sso_build_response`]. `expected_redirect_host` is itself fuzzed +/// input rather than a fixed value, so the mismatch path is exercised too. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_validate_sso_build_response(build_output: &str, expected_redirect_host: &str) { + let _ = validate_sso_build_response("fuzz", build_output, expected_redirect_host); +} + +/// Fuzz target entry point for parsing the otherwise-private `AuthResultMsg` +/// every guest response (`auth`, `sso_parse_callback`) is deserialized +/// through. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_parse_auth_result(output: &str) { + let _ = serde_json::from_str::(output); } diff --git a/sdk/plugin-wasm/src/provenance.rs b/sdk/plugin-wasm/src/provenance.rs new file mode 100644 index 000000000..d5e98794d --- /dev/null +++ b/sdk/plugin-wasm/src/provenance.rs @@ -0,0 +1,680 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Verification of GitHub artifact attestations (Sigstore bundles produced +//! by `actions/attest`) proving a registry-listed plugin artifact was +//! actually published by CI in its claimed source repository, rather than +//! just checksum-consistent with what the registry index says. +//! +//! This is a deliberately reduced verifier, not a general Sigstore client: +//! it checks the DSSE envelope's signature against the attestation's leaf +//! (Fulcio-issued) certificate, that the leaf certificate chains to a +//! vendored, pinned Fulcio root/intermediate CA (`../trust/*.pem`), and that +//! the leaf certificate's GitHub Actions identity extensions (OIDC issuer + +//! `owner/repo`) match the plugin's claimed source repository. It does +//! **not** verify the Rekor transparency-log inclusion proof (the Merkle +//! audit path + signed tree head) — that needs a TUF trust-root client and +//! Merkle-proof code that only the `sigstore` crate provides today, and that +//! crate's dependency graph (a TUF client, a possible OpenSSL pull-through) +//! conflicts with this workspace's explicit no-OpenSSL stance. This is the +//! same tradeoff `cosign verify-blob --insecure-ignore-tlog` documents, +//! chosen deliberately here rather than silently: a tampered or unattested +//! plugin still fails closed, but a withheld-from-the-public-log attestation +//! is not detected. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::Utc; +use ring::signature::{self, UnparsedPublicKey}; +use serde::Deserialize; +use x509_parser::certificate::X509Certificate; +use x509_parser::extensions::GeneralName; +use x509_parser::pem::Pem; +use x509_parser::prelude::FromDer; + +use crate::error::WasmPluginError; +use crate::lockfile::ProvenanceRecord; + +/// The GitHub REST API host attestations are fetched from by default. +pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; + +/// Fulcio's "OIDC issuer" certificate extension OID. +const OID_OIDC_ISSUER: &str = "1.3.6.1.4.1.57264.1.1"; +/// Fulcio's "source repository" (`owner/repo`) certificate extension OID. +const OID_SOURCE_REPO: &str = "1.3.6.1.4.1.57264.1.5"; +/// The only OIDC issuer a GitHub Actions-signed certificate can have. +const EXPECTED_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; + +const FULCIO_ROOT_PEM: &str = include_str!("../trust/fulcio_root.pem"); +const FULCIO_INTERMEDIATE_PEM: &str = include_str!("../trust/fulcio_intermediate.pem"); + +/// The pinned Fulcio root + intermediate CA certificates a leaf certificate +/// must chain to before its signature is trusted. +pub struct TrustRoots { + root_der: Vec, + intermediate_der: Vec, +} + +impl TrustRoots { + /// The vendored, pinned Sigstore public-good-instance Fulcio root and + /// intermediate CAs (`sdk/plugin-wasm/trust/*.pem`). + pub fn production() -> Result { + Self::from_pem( + FULCIO_ROOT_PEM.as_bytes(), + FULCIO_INTERMEDIATE_PEM.as_bytes(), + ) + } + + /// Build from arbitrary PEM bytes. Exposed so tests can substitute a + /// synthetic root/intermediate pair, making the certificate-chain and + /// DSSE-signature verification path exercisable without a real + /// GitHub-signed artifact. + pub fn from_pem(root_pem: &[u8], intermediate_pem: &[u8]) -> Result { + Ok(Self { + root_der: pem_to_der(root_pem)?, + intermediate_der: pem_to_der(intermediate_pem)?, + }) + } + + fn root(&self) -> Result, WasmPluginError> { + parse_cert(&self.root_der, "vendored Fulcio root") + } + + fn intermediate(&self) -> Result, WasmPluginError> { + parse_cert(&self.intermediate_der, "vendored Fulcio intermediate") + } +} + +fn pem_to_der(pem_bytes: &[u8]) -> Result, WasmPluginError> { + let pem = Pem::iter_from_buffer(pem_bytes) + .next() + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: "no PEM block found in trust anchor".into(), + })? + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid trust anchor PEM: {e}"), + })?; + Ok(pem.contents) +} + +fn parse_cert<'a>(der: &'a [u8], what: &str) -> Result, WasmPluginError> { + let (_, cert) = + X509Certificate::from_der(der).map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid {what} certificate: {e}"), + })?; + Ok(cert) +} + +/// The response body of `GET /repos/{owner}/{repo}/attestations/{subject_digest}`. +#[derive(Clone, Debug, Deserialize)] +pub struct AttestationsResponse { + /// Attestations found for the given subject digest. + #[serde(default)] + pub attestations: Vec, +} + +/// A single attestation entry. +#[derive(Clone, Debug, Deserialize)] +pub struct Attestation { + /// The Sigstore bundle itself. + pub bundle: Bundle, +} + +/// A Sigstore bundle: a signed statement plus the material needed to verify +/// it. Only the fields this verifier uses are modeled; unknown fields +/// (`mediaType`, `bundle_url`, ...) are ignored by `serde` by default. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bundle { + /// The signed DSSE envelope. + pub dsse_envelope: DsseEnvelope, + /// The certificate(s) and transparency-log entries backing the + /// signature. + pub verification_material: VerificationMaterial, +} + +/// A DSSE (Dead Simple Signing Envelope) as used by in-toto attestations. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DsseEnvelope { + /// Base64-encoded payload (an in-toto statement, not itself parsed by + /// this verifier — only its signature is checked). + pub payload: String, + /// The payload's media type, part of the signed pre-authentication + /// encoding. + pub payload_type: String, + /// Signatures over the payload. Only the first is checked. + #[serde(default)] + pub signatures: Vec, +} + +/// A single DSSE signature. +#[derive(Clone, Debug, Deserialize)] +pub struct DsseSignature { + /// Base64-encoded signature bytes (ASN.1 DER ECDSA signature). + pub sig: String, +} + +/// The certificate(s) and transparency-log entries a bundle carries. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerificationMaterial { + /// A single leaf certificate, in newer bundle media types. + #[serde(default)] + pub certificate: Option, + /// A certificate chain, in older bundle media types. When present and + /// `certificate` is absent, its first entry is the leaf. + #[serde(default)] + pub x509_certificate_chain: Option, + /// Rekor transparency-log entries, if any. **Not** verified for Merkle + /// inclusion by this verifier — see module docs. + #[serde(default)] + pub tlog_entries: Vec, +} + +impl VerificationMaterial { + fn leaf_certificate_der(&self) -> Result, WasmPluginError> { + let raw_bytes = self + .certificate + .as_ref() + .map(|c| c.raw_bytes.as_str()) + .or_else(|| { + self.x509_certificate_chain + .as_ref() + .and_then(|c| c.certificates.first()) + .map(|c| c.raw_bytes.as_str()) + }) + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: "attestation bundle has no leaf certificate".into(), + })?; + BASE64 + .decode(raw_bytes) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid leaf certificate base64: {e}"), + }) + } +} + +/// A single DER certificate, base64-encoded. +#[derive(Clone, Debug, Deserialize)] +pub struct RawCert { + /// Base64-encoded DER certificate bytes. + #[serde(rename = "rawBytes")] + pub raw_bytes: String, +} + +/// A chain of DER certificates. +#[derive(Clone, Debug, Deserialize)] +pub struct CertChain { + /// Leaf-first certificate chain. + #[serde(default)] + pub certificates: Vec, +} + +/// A single Rekor transparency-log entry reference. Only kept for display; +/// not verified. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TlogEntry { + /// The log index, if present. GitHub's API has represented this as both + /// a JSON string and a JSON number across bundle media types, so it's + /// captured generically and stringified for display. + #[serde(default)] + pub log_index: Option, +} + +/// Fetch the GitHub artifact attestations for the artifact whose sha256 +/// digest is `sha256_hex`, from `{base_url}/repos/{owner}/{repo}/attestations/...`. +/// +/// `base_url` is a parameter (rather than hardcoded) so tests can point this +/// at an `httpmock` server; production callers should pass +/// [`GITHUB_API_BASE_URL`]. An optional `GITHUB_TOKEN` environment variable +/// is sent as a bearer token if set — purely a rate-limit/private-repo +/// convenience, never required and never a factor in what ends up trusted. +pub async fn fetch_attestations( + base_url: &str, + owner: &str, + repo: &str, + sha256_hex: &str, + client: &reqwest::Client, +) -> Result { + let url = format!("{base_url}/repos/{owner}/{repo}/attestations/sha256:{sha256_hex}"); + let mut request = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + request = request.bearer_auth(token); + } + let to_fetch_err = |source| WasmPluginError::AttestationFetch { + owner: owner.to_string(), + repo: repo.to_string(), + source, + }; + let response = request + .send() + .await + .map_err(to_fetch_err)? + .error_for_status() + .map_err(to_fetch_err)?; + let bytes = response.bytes().await.map_err(to_fetch_err)?; + serde_json::from_slice(&bytes).map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("malformed attestations response: {e}"), + }) +} + +/// Fetch and verify GitHub artifact attestations for `sha256_hex`, claimed +/// to have been published from `source_repo` (`owner/repo`), against the +/// production trust roots. Returns the first attestation that verifies +/// successfully; if none do, returns the last verification error +/// encountered (or a "no attestations found" error if none exist at all). +pub async fn verify_for_source( + source_repo: &str, + sha256_hex: &str, + client: &reqwest::Client, +) -> Result { + let (owner, repo) = + source_repo + .split_once('/') + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: format!( + "registry entry source_repo `{source_repo}` is not in `owner/repo` form" + ), + })?; + let response = fetch_attestations(GITHUB_API_BASE_URL, owner, repo, sha256_hex, client).await?; + let roots = TrustRoots::production()?; + + let mut last_err = WasmPluginError::AttestationVerification { + reason: "no attestations found for this artifact".into(), + }; + for attestation in &response.attestations { + match verify_attestation(&attestation.bundle, owner, repo, &roots) { + Ok(record) => return Ok(record), + Err(e) => last_err = e, + } + } + Err(last_err) +} + +/// Verify a single attestation bundle's DSSE signature, certificate chain, +/// and GitHub Actions identity against `expected_owner`/`expected_repo`. See +/// module docs for exactly what is and isn't checked. +pub fn verify_attestation( + bundle: &Bundle, + expected_owner: &str, + expected_repo: &str, + roots: &TrustRoots, +) -> Result { + let payload = BASE64 + .decode(bundle.dsse_envelope.payload.as_bytes()) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid DSSE payload base64: {e}"), + })?; + let signature = bundle.dsse_envelope.signatures.first().ok_or_else(|| { + WasmPluginError::AttestationVerification { + reason: "DSSE envelope has no signatures".into(), + } + })?; + let sig_bytes = BASE64.decode(signature.sig.as_bytes()).map_err(|e| { + WasmPluginError::AttestationVerification { + reason: format!("invalid DSSE signature base64: {e}"), + } + })?; + let pae = dsse_pae(&bundle.dsse_envelope.payload_type, &payload); + + let leaf_der = bundle.verification_material.leaf_certificate_der()?; + let leaf = parse_cert(&leaf_der, "attestation leaf")?; + + let leaf_key = UnparsedPublicKey::new( + &signature::ECDSA_P256_SHA256_ASN1, + leaf.public_key().subject_public_key.data.as_ref(), + ); + leaf_key + .verify(&pae, &sig_bytes) + .map_err(|_| WasmPluginError::AttestationVerification { + reason: "DSSE envelope signature verification failed".into(), + })?; + + // Chain the leaf to our own vendored, pinned root/intermediate — + // deliberately ignoring any intermediate/root certificates the bundle + // itself supplied, since trusting the server's own chain would defeat + // the point of pinning. + let intermediate = roots.intermediate()?; + let root = roots.root()?; + leaf.verify_signature(Some(intermediate.public_key())) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!( + "leaf certificate does not chain to the pinned Fulcio intermediate: {e}" + ), + })?; + intermediate + .verify_signature(Some(root.public_key())) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!( + "pinned Fulcio intermediate does not chain to the pinned Fulcio root: {e}" + ), + })?; + + let oidc_issuer = extension_string(&leaf, OID_OIDC_ISSUER); + if oidc_issuer.as_deref() != Some(EXPECTED_OIDC_ISSUER) { + return Err(WasmPluginError::AttestationVerification { + reason: format!("unexpected OIDC issuer: {oidc_issuer:?}"), + }); + } + let expected_repository = format!("{expected_owner}/{expected_repo}"); + let repository = extension_string(&leaf, OID_SOURCE_REPO); + if repository.as_deref() != Some(expected_repository.as_str()) { + return Err(WasmPluginError::AttestationVerification { + reason: format!( + "certificate identity `{repository:?}` does not match claimed source repo `{expected_repository}`" + ), + }); + } + + let workflow_ref = subject_alternative_name_uri(&leaf); + let rekor_log_index = bundle + .verification_material + .tlog_entries + .first() + .and_then(|e| e.log_index.as_ref()) + .map(value_to_display_string); + + Ok(ProvenanceRecord { + source_repo: expected_repository, + workflow_ref, + oidc_issuer, + rekor_log_index, + verified_at: Utc::now(), + }) +} + +fn value_to_display_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// The DSSE Pre-Authentication Encoding: the exact byte structure that gets +/// signed. See +/// . +fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec { + let mut pae = Vec::with_capacity(payload.len() + payload_type.len() + 32); + pae.extend_from_slice(b"DSSEv1"); + pae.push(b' '); + pae.extend_from_slice(payload_type.len().to_string().as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload_type.as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload.len().to_string().as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload); + pae +} + +/// Best-effort extraction of a Fulcio identity extension's string value. +/// These extensions hold their content as a primitive ASN.1 string TLV +/// (tag + DER length + UTF-8 bytes); rather than pull in a generic ASN.1 +/// value parser for this one shape, the two-or-more byte header is skipped +/// directly. +fn extension_string(cert: &X509Certificate, oid: &str) -> Option { + let ext = cert + .extensions() + .iter() + .find(|e| e.oid.to_id_string() == oid)?; + decode_der_string_content(ext.value) +} + +fn decode_der_string_content(value: &[u8]) -> Option { + let (_tag, rest) = value.split_first()?; + let (len_byte, rest) = rest.split_first()?; + let content = if *len_byte < 0x80 { + rest.get(..*len_byte as usize)? + } else { + let n = (*len_byte & 0x7f) as usize; + let len_bytes = rest.get(..n)?; + let len = len_bytes + .iter() + .fold(0usize, |acc, b| (acc << 8) | *b as usize); + rest.get(n..n + len)? + }; + std::str::from_utf8(content).ok().map(|s| s.to_string()) +} + +fn subject_alternative_name_uri(cert: &X509Certificate) -> Option { + let san = cert.subject_alternative_name().ok().flatten()?; + san.value.general_names.iter().find_map(|gn| match gn { + GeneralName::URI(uri) => Some((*uri).to_string()), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dsse_pae_matches_spec_example() { + // From the DSSE spec's own worked example: PAE = "DSSEv1" SP + // LEN(type) SP type SP LEN(body) SP body. + let payload_type = "http://example.com/HelloWorld"; + let payload = b"hello world"; + let pae = dsse_pae(payload_type, payload); + let expected = format!( + "DSSEv1 {} {payload_type} {} {}", + payload_type.len(), + payload.len(), + "hello world" + ); + assert_eq!(pae, expected.into_bytes()); + } + + #[test] + fn decode_der_string_content_strips_short_form_header() { + // UTF8String (tag 0x0c), length 5, "hello" + let der = [0x0c, 0x05, b'h', b'e', b'l', b'l', b'o']; + assert_eq!(decode_der_string_content(&der).as_deref(), Some("hello")); + } + + /// A synthetic root -> intermediate -> leaf certificate chain, shaped + /// like what Fulcio issues (leaf carrying a SAN workflow-ref URI and the + /// two GitHub Actions identity extension OIDs), built with `rcgen` so the + /// DSSE-signature and certificate-chain verification paths in + /// [`verify_attestation`] are exercisable without a real GitHub-signed + /// artifact. Not related to (and not a stand-in for) the real, + /// vendored Fulcio roots in [`TrustRoots::production`]. + struct SyntheticChain { + root_pem: String, + intermediate_pem: String, + leaf_der: Vec, + leaf_signing_key: ring::signature::EcdsaKeyPair, + } + + fn der_utf8_string(s: &str) -> Vec { + assert!(s.len() < 128, "test helper only supports short strings"); + let mut v = vec![0x0c, s.len() as u8]; + v.extend_from_slice(s.as_bytes()); + v + } + + fn build_synthetic_chain( + oidc_issuer: &str, + source_repo: &str, + workflow_ref: &str, + ) -> SyntheticChain { + use rcgen::{ + BasicConstraints, CertificateParams, CustomExtension, Ia5String, IsCa, KeyPair, SanType, + }; + + let mut root_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let root_key = KeyPair::generate().expect("key generation"); + let root_cert = root_params.self_signed(&root_key).expect("self-sign root"); + + let mut inter_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + inter_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + let inter_key = KeyPair::generate().expect("key generation"); + let inter_cert = inter_params + .signed_by(&inter_key, &root_cert, &root_key) + .expect("sign intermediate with root"); + + let mut leaf_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + leaf_params.is_ca = IsCa::NoCa; + leaf_params.subject_alt_names = vec![SanType::URI( + Ia5String::try_from(workflow_ref).expect("valid IA5 URI"), + )]; + leaf_params + .custom_extensions + .push(CustomExtension::from_oid_content( + &[1, 3, 6, 1, 4, 1, 57264, 1, 1], + der_utf8_string(oidc_issuer), + )); + leaf_params + .custom_extensions + .push(CustomExtension::from_oid_content( + &[1, 3, 6, 1, 4, 1, 57264, 1, 5], + der_utf8_string(source_repo), + )); + let leaf_key = KeyPair::generate().expect("key generation"); + let leaf_cert = leaf_params + .signed_by(&leaf_key, &inter_cert, &inter_key) + .expect("sign leaf with intermediate"); + + let leaf_signing_key = ring::signature::EcdsaKeyPair::from_pkcs8( + &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, + leaf_key.serialize_der().as_slice(), + &ring::rand::SystemRandom::new(), + ) + .expect("rcgen's PKCS#8 output loads into ring"); + + SyntheticChain { + root_pem: root_cert.pem(), + intermediate_pem: inter_cert.pem(), + leaf_der: leaf_cert.der().to_vec(), + leaf_signing_key, + } + } + + const TEST_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; + + fn build_bundle(chain: &SyntheticChain, payload: &[u8]) -> Bundle { + let pae = dsse_pae(TEST_PAYLOAD_TYPE, payload); + let sig = chain + .leaf_signing_key + .sign(&ring::rand::SystemRandom::new(), &pae) + .expect("sign PAE"); + Bundle { + dsse_envelope: DsseEnvelope { + payload: BASE64.encode(payload), + payload_type: TEST_PAYLOAD_TYPE.to_string(), + signatures: vec![DsseSignature { + sig: BASE64.encode(sig.as_ref()), + }], + }, + verification_material: VerificationMaterial { + certificate: Some(RawCert { + raw_bytes: BASE64.encode(&chain.leaf_der), + }), + x509_certificate_chain: None, + tlog_entries: vec![], + }, + } + } + + const TEST_WORKFLOW_REF: &str = "https://github.com/gtema/example-auth-plugin/.github/workflows/release.yml@refs/heads/main"; + + #[test] + fn verify_attestation_accepts_valid_synthetic_chain() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + let record = verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots) + .expect("valid attestation should verify"); + assert_eq!(record.source_repo, "gtema/example-auth-plugin"); + assert_eq!(record.oidc_issuer.as_deref(), Some(EXPECTED_OIDC_ISSUER)); + assert_eq!(record.workflow_ref.as_deref(), Some(TEST_WORKFLOW_REF)); + } + + #[test] + fn verify_attestation_rejects_tampered_payload() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let mut bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + // Swap in a different (still validly base64/JSON-shaped) payload + // after signing, without re-signing — this is what a tampered or + // substituted artifact attestation looks like on the wire. + bundle.dsse_envelope.payload = BASE64.encode(b"{\"_type\":\"tampered\"}"); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + assert!(verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots).is_err()); + } + + #[test] + fn verify_attestation_rejects_repository_mismatch() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + // The certificate's repository extension says gtema/example-auth-plugin; + // the caller expects a different repository entirely (as would happen + // if a registry index entry's source_repo were spoofed/mismatched). + assert!(verify_attestation(&bundle, "someone-else", "unrelated-repo", &roots).is_err()); + } + + #[test] + fn verify_attestation_rejects_chain_not_rooted_in_pinned_ca() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + // A second, unrelated synthetic root/intermediate pair the leaf was + // never actually signed by — simulates an attacker presenting their + // own CA instead of the pinned Fulcio one. + let other = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let roots = + TrustRoots::from_pem(other.root_pem.as_bytes(), other.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + assert!(verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots).is_err()); + } +} diff --git a/sdk/plugin-wasm/src/registry.rs b/sdk/plugin-wasm/src/registry.rs index 132763a4b..353c859d0 100644 --- a/sdk/plugin-wasm/src/registry.rs +++ b/sdk/plugin-wasm/src/registry.rs @@ -42,8 +42,10 @@ use std::sync::{Arc, OnceLock, RwLock}; use chrono::Utc; use crate::error::WasmPluginError; -use crate::lockfile::{self, PluginEntry, PluginLockfile, TrustInfo}; +use crate::index::{self, IndexEntry, IndexVersion}; +use crate::lockfile::{self, PluginEntry, PluginLockfile, ProvenanceRecord, TrustInfo}; use crate::plugin::WasmAuthPlugin; +use crate::provenance; /// Version recorded for a plugin installed without an explicit version. pub const DEFAULT_VERSION: &str = "0.0.0"; @@ -235,6 +237,8 @@ pub fn install( confirmed_by_user: true, allow_unsigned: true, }, + pinned: false, + provenance: None, }, ); lf.active.insert(name.clone(), version.clone()); @@ -350,3 +354,344 @@ pub fn verify(name: &str, version: Option<&str>) -> Result, Was } Ok(entries) } + +/// The result of verifying a planned remote install's provenance. +#[derive(Debug)] +pub enum ProvenanceOutcome { + /// A GitHub artifact attestation was fetched and successfully verified + /// against the plugin's claimed `source_repo`. + Verified(ProvenanceRecord), + /// No attestation could be verified; `reason` explains why. Proceeding + /// with the install requires an explicit `--allow-unsigned`. + Unverified { + /// Human readable reason verification did not succeed. + reason: String, + }, +} + +/// A remote install/update that has been resolved against a registry index, +/// downloaded, checksum-verified, and had provenance verification attempted +/// — but not yet written to disk. Built by [`plan_remote_install`] (or, +/// during [`update`], the equivalent internal step); the CLI shows this to +/// the user for confirmation before calling [`finalize_install`], keeping +/// this SDK crate itself free of interactive/UI concerns. +pub struct PendingInstall { + /// Plugin name. + pub name: String, + /// Resolved version. + pub version: String, + /// The `owner/repo` this version claims to be published from. + pub source_repo: String, + /// Lowercase hex-encoded sha256 of the downloaded bytes (already + /// verified against the registry index's declared value). + pub sha256: String, + /// The downloaded `.wasm` bytes, not yet written to disk. + bytes: Vec, + /// Provenance verification result. + pub provenance: ProvenanceOutcome, +} + +async fn plan_from_resolved( + entry: &IndexEntry, + iv: &IndexVersion, + client: &reqwest::Client, +) -> Result { + let bytes = index::download(&entry.name, iv, client).await?; + let sha256 = iv.sha256.to_lowercase(); + let provenance = match provenance::verify_for_source(&iv.source_repo, &sha256, client).await { + Ok(record) => ProvenanceOutcome::Verified(record), + Err(e) => ProvenanceOutcome::Unverified { + reason: e.to_string(), + }, + }; + Ok(PendingInstall { + name: entry.name.clone(), + version: iv.version.clone(), + source_repo: iv.source_repo.clone(), + sha256, + bytes, + provenance, + }) +} + +/// Fetch the registry index at `registry_url`, resolve `name`[`@version`], +/// download and checksum-verify the artifact, and attempt provenance +/// verification against its claimed `source_repo` — all before anything is +/// written to disk. +pub async fn plan_remote_install( + name: &str, + version: Option<&str>, + registry_url: &str, + client: &reqwest::Client, +) -> Result { + let idx = index::fetch_index(registry_url, client).await?; + let entry = + idx.plugins + .iter() + .find(|e| e.name == name) + .ok_or_else(|| WasmPluginError::NotInIndex { + name: name.to_string(), + version: version.map(|v| v.to_string()), + })?; + let iv = index::resolve_version(entry, version)?; + plan_from_resolved(entry, iv, client).await +} + +/// Write a [`PendingInstall`] to disk and record it in the lockfile, making +/// it the active version for its name. +/// +/// Refuses with [`WasmPluginError::Untrusted`] if provenance verification +/// did not succeed and `allow_unsigned` is `false` — nothing is written to +/// disk in that case. Refuses with [`WasmPluginError::AlreadyInstalled`] if +/// this exact `name@version` is already installed and `force` is `false` +/// (mirroring [`install`]'s same guard for local files). `pinned` should be +/// `true` when the user requested an explicit `@version` (so `update --all` +/// skips it later), `false` when they asked for "latest". +pub fn finalize_install( + pending: PendingInstall, + allow_unsigned: bool, + pinned: bool, + force: bool, +) -> Result, WasmPluginError> { + let key = lockfile::entry_key(&pending.name, &pending.version); + if !force && PluginLockfile::load()?.plugins.contains_key(&key) { + return Err(WasmPluginError::AlreadyInstalled(format!( + "{}@{}", + pending.name, pending.version + ))); + } + + let provenance_record = match &pending.provenance { + ProvenanceOutcome::Verified(record) => Some(record.clone()), + ProvenanceOutcome::Unverified { reason } => { + if !allow_unsigned { + return Err(WasmPluginError::Untrusted { + name: pending.name.clone(), + version: pending.version.clone(), + reason: reason.clone(), + }); + } + None + } + }; + + let dest_dir = version_dir(&pending.name, &pending.version)?; + fs::create_dir_all(&dest_dir).map_err(|source| WasmPluginError::Io { + path: dest_dir.clone(), + source, + })?; + let dest = dest_dir.join(format!("{}.wasm", pending.name)); + // Staged in a `.tmp` subdirectory, not as a `.wasm.tmp` sibling file: + // `WasmAuthPlugin::name()` is the loaded file's `file_stem()`, which only + // strips the *last* extension, so a `.wasm.tmp` file would probe as + // named `.wasm` and always fail the check below. Keeping the tmp + // file's own name exactly `.wasm` (just in a scratch subdirectory) + // keeps that check meaningful while still renaming atomically into place + // afterwards (same filesystem, since it's a subdirectory of `dest_dir`). + let tmp_dir = dest_dir.join(".tmp"); + fs::create_dir_all(&tmp_dir).map_err(|source| WasmPluginError::Io { + path: tmp_dir.clone(), + source, + })?; + let tmp = tmp_dir.join(format!("{}.wasm", pending.name)); + fs::write(&tmp, &pending.bytes).map_err(|source| WasmPluginError::Io { + path: tmp.clone(), + source, + })?; + + // Validate before finalizing the file into place, mirroring `install`'s + // "never leave an unloadable module recorded as installed" guarantee. + let probe = WasmAuthPlugin::load(&tmp).inspect_err(|_| { + let _ = fs::remove_file(&tmp); + let _ = fs::remove_dir(&tmp_dir); + })?; + if probe.name() != pending.name { + let _ = fs::remove_file(&tmp); + let _ = fs::remove_dir(&tmp_dir); + return Err(WasmPluginError::InvalidAbi { + name: pending.name.clone(), + reason: format!( + "registry entry name `{}` does not match the plugin's own name `{}`", + pending.name, + probe.name() + ), + }); + } + fs::rename(&tmp, &dest).map_err(|source| WasmPluginError::Io { + path: dest.clone(), + source, + })?; + let _ = fs::remove_dir(&tmp_dir); + let sha256 = lockfile::sha256_hex(&dest)?; + + let mut lf = PluginLockfile::load()?; + lf.plugins.insert( + lockfile::entry_key(&pending.name, &pending.version), + PluginEntry { + name: pending.name.clone(), + version: pending.version.clone(), + sha256, + source: PathBuf::from(format!("registry:{}", pending.source_repo)), + installed_at: Utc::now(), + trust: TrustInfo { + confirmed_by_user: true, + allow_unsigned, + }, + pinned, + provenance: provenance_record, + }, + ); + lf.active + .insert(pending.name.clone(), pending.version.clone()); + lf.save()?; + + let mut reg = lock_write()?; + load_active(&mut reg, &lf, &pending.name)?; + reg.by_name + .get(&pending.name) + .cloned() + .ok_or_else(|| WasmPluginError::NotInstalled { + name: pending.name.clone(), + version: Some(pending.version.clone()), + }) +} + +/// The outcome of one plugin's update attempt. +#[derive(Debug)] +pub enum UpdateOutcome { + /// Already at the latest version available in the registry index. + UpToDate { + /// Plugin name. + name: String, + /// The version already installed and active. + version: String, + }, + /// Updated to a new version. + Updated { + /// Plugin name. + name: String, + /// The version that was previously active. + from: String, + /// The newly active version. + to: String, + }, + /// The user declined the confirmation for this update; left untouched. + Declined { + /// Plugin name. + name: String, + /// The version that remains active. + version: String, + }, + /// Skipped because the active version is pinned (explicit `@version` + /// install): `update --all` never changes a pinned plugin. + SkippedPinned { + /// Plugin name. + name: String, + /// The pinned, still-active version. + version: String, + }, + /// The plugin is installed locally but no longer listed in the registry + /// index. + NotInIndex { + /// Plugin name. + name: String, + }, +} + +/// Update installed, non-pinned plugin(s) to the latest version available in +/// the registry index at `registry_url`. +/// +/// If `name` is `Some`, only that plugin is considered; if it's pinned this +/// returns an error (pinned means the user explicitly asked to stay on that +/// version — use `install @` to change it deliberately). If +/// `name` is `None`, `all` must be `true`, and every installed plugin is +/// considered, with pinned ones reported as [`UpdateOutcome::SkippedPinned`] +/// rather than erroring. +/// +/// For each candidate whose latest index version differs from what's +/// active, `confirm` is called with the planned install so the caller can +/// prompt the user; declining leaves that plugin untouched. Provenance is +/// re-verified fresh for every candidate on every call — no previously +/// recorded [`crate::lockfile::ProvenanceRecord`] is ever reused. +pub async fn update( + name: Option<&str>, + all: bool, + registry_url: &str, + client: &reqwest::Client, + allow_unsigned: bool, + mut confirm: impl FnMut(&PendingInstall) -> bool, +) -> Result, WasmPluginError> { + let lf = PluginLockfile::load()?; + + let candidates: Vec = match (name, all) { + (Some(n), _) => { + let entry = lf + .active_entry(n) + .ok_or_else(|| WasmPluginError::NotInstalled { + name: n.to_string(), + version: None, + })?; + if entry.pinned { + return Err(WasmPluginError::Registry(format!( + "{n}@{} is pinned to an explicit version; run `osc plugin install {n}@` to change it", + entry.version + ))); + } + vec![entry.clone()] + } + (None, true) => lf + .active + .keys() + .filter_map(|n| lf.active_entry(n).cloned()) + .collect(), + (None, false) => { + return Err(WasmPluginError::Registry( + "update requires either a plugin name or --all".into(), + )); + } + }; + + let idx = index::fetch_index(registry_url, client).await?; + + let mut outcomes = Vec::new(); + for entry in candidates { + if entry.pinned { + outcomes.push(UpdateOutcome::SkippedPinned { + name: entry.name, + version: entry.version, + }); + continue; + } + let Some(idx_entry) = idx.plugins.iter().find(|e| e.name == entry.name) else { + outcomes.push(UpdateOutcome::NotInIndex { name: entry.name }); + continue; + }; + let latest = index::resolve_version(idx_entry, None)?; + if latest.version == entry.version { + outcomes.push(UpdateOutcome::UpToDate { + name: entry.name, + version: entry.version, + }); + continue; + } + + let pending = plan_from_resolved(idx_entry, latest, client).await?; + if !confirm(&pending) { + outcomes.push(UpdateOutcome::Declined { + name: entry.name, + version: entry.version, + }); + continue; + } + let from = entry.version; + let to = pending.version.clone(); + let updated_name = pending.name.clone(); + finalize_install(pending, allow_unsigned, false, true)?; + outcomes.push(UpdateOutcome::Updated { + name: updated_name, + from, + to, + }); + } + Ok(outcomes) +} diff --git a/sdk/plugin-wasm/tests/fixtures/example_sso.wasm b/sdk/plugin-wasm/tests/fixtures/example_sso.wasm new file mode 100755 index 000000000..ac4ce863c Binary files /dev/null and b/sdk/plugin-wasm/tests/fixtures/example_sso.wasm differ diff --git a/sdk/plugin-wasm/tests/registry_remote.rs b/sdk/plugin-wasm/tests/registry_remote.rs new file mode 100644 index 000000000..87a642fac --- /dev/null +++ b/sdk/plugin-wasm/tests/registry_remote.rs @@ -0,0 +1,137 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test of the remote registry install path: a mocked registry +//! index + download served over HTTP, resolved and checksum-verified by +//! [`registry::plan_remote_install`], then finalized by +//! [`registry::finalize_install`]. +//! +//! The plugin's declared `source_repo` here is deliberately not a real, +//! attested repository, so provenance verification comes back `Unverified` +//! deterministically (whether this sandbox's egress proxy blocks the GitHub +//! API call outright or a reachable GitHub API simply has no attestations +//! for this made-up repo, the result is the same `Unverified` outcome +//! either way) — exactly the case that must fail closed without +//! `--allow-unsigned`, and succeed (loudly) with it. Provenance +//! *verification itself* (valid/tampered synthetic attestation) is covered +//! separately, and without any network dependency, by the `rcgen`-based +//! unit tests in `src/provenance.rs`. +//! +//! This is the only test in this binary so it can safely set the +//! process-wide `OSC_PLUGIN_DIR`/`OSC_PLUGIN_LOCKFILE` environment variables, +//! mirroring `tests/registry.rs`. + +use std::path::PathBuf; + +use openstack_sdk_plugin_wasm::index; +use openstack_sdk_plugin_wasm::lockfile; +use openstack_sdk_plugin_wasm::registry; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/example_auth.wasm") +} + +#[tokio::test(flavor = "multi_thread")] +async fn untrusted_install_fails_closed_and_allow_unsigned_is_the_only_way_past_it() +-> Result<(), Box> { + let plugin_dir = tempfile::tempdir()?; + let lockfile_dir = tempfile::tempdir()?; + // SAFETY: this is the only test in this binary, so nothing else races on + // the process environment. + unsafe { + std::env::set_var("OSC_PLUGIN_DIR", plugin_dir.path()); + std::env::set_var( + "OSC_PLUGIN_LOCKFILE", + lockfile_dir.path().join("plugins.lock"), + ); + } + + let fixture = fixture_path(); + let sha256 = lockfile::sha256_hex(&fixture)?; + let bytes = std::fs::read(&fixture)?; + + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200).json_body(serde_json::json!({ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Example auth plugin", + "versions": [ + { + "version": "1.0.0", + "download_url": format!("{}/example_auth.wasm", server.base_url()), + "sha256": sha256, + // Not a real, attested repository: provenance + // verification is expected to come back + // Unverified for it, deterministically. + "source_repo": "gtema/nonexistent-plugin-fixture-xyz" + } + ] + } + ] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/example_auth.wasm"); + then.status(200).body(bytes); + }); + + let client = index::http_client()?; + let registry_url = format!("{}/index.json", server.base_url()); + + // Refused before anything is written to disk, without --allow-unsigned. + let pending = + registry::plan_remote_install("example_auth", None, ®istry_url, &client).await?; + assert!(matches!( + pending.provenance, + registry::ProvenanceOutcome::Unverified { .. } + )); + let err = registry::finalize_install(pending, false, false, false).unwrap_err(); + assert!(matches!( + err, + openstack_sdk_plugin_wasm::error::WasmPluginError::Untrusted { .. } + )); + assert!( + !registry::plugin_root()?.join("example_auth").exists(), + "a refused install must not leave anything on disk" + ); + assert!( + registry::installed()? + .entry("example_auth", "1.0.0") + .is_none() + ); + + // The same plan, but with the explicit escape hatch, succeeds and is + // recorded as unsigned. + let pending = + registry::plan_remote_install("example_auth", None, ®istry_url, &client).await?; + registry::finalize_install(pending, true, false, false)?; + + let lockfile = registry::installed()?; + let entry = lockfile + .entry("example_auth", "1.0.0") + .ok_or("expected example_auth@1.0.0 to be installed")?; + assert!(entry.trust.allow_unsigned); + assert!(entry.provenance.is_none()); + assert_eq!( + lockfile.active.get("example_auth").map(String::as_str), + Some("1.0.0") + ); + + Ok(()) +} diff --git a/sdk/plugin-wasm/tests/wasm_sso_plugin.rs b/sdk/plugin-wasm/tests/wasm_sso_plugin.rs new file mode 100644 index 000000000..195785476 --- /dev/null +++ b/sdk/plugin-wasm/tests/wasm_sso_plugin.rs @@ -0,0 +1,179 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the `sso` ABI flavor, exercising a real Extism +//! (WASM) SSO plugin built from +//! `sdk/plugin-wasm/fixtures/example-sso-plugin` and checked in as +//! `tests/fixtures/example_sso.wasm`. +//! +//! What's automatable in a headless sandbox vs. not: +//! +//! - The host-side security checks (`https://`-only, redirect-host must +//! match the host-bound callback listener) run *before* +//! [`WasmAuthPlugin::auth`] ever prompts for confirmation or opens a +//! browser, so they're fully exercised here through the real public +//! `auth()` entry point. +//! - A full happy-path run additionally needs an interactive confirmation +//! (`dialoguer::Confirm`, reads a real terminal) and a real browser — +//! neither exists in this test environment, matching the same +//! can't-verify-live-here caveat already documented for GitHub +//! attestations in `provenance.rs`'s tests. Instead, the guest ABI's own +//! correctness (`sso_build_request`/`sso_parse_callback` shapes) is +//! verified directly against a raw [`extism::Plugin`], and the shared +//! anti-CSRF callback listener `auth_via_sso` relies on +//! (`openstack_sdk_websso_host::CallbackServer`) is exercised end-to-end +//! here too, forged state included. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use extism::{Manifest, Plugin, Wasm}; +use secrecy::SecretString; + +use openstack_sdk_auth_core::OpenStackAuthType; +use openstack_sdk_plugin_wasm::WasmAuthPlugin; +use openstack_sdk_websso_host::CallbackServer; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/example_sso.wasm") +} + +fn raw_plugin() -> Result> { + let manifest = Manifest::new([Wasm::file(fixture_path())]).disallow_all_hosts(); + Ok(Plugin::new(manifest, [], false)?) +} + +#[test] +fn load_validates_sso_abi() -> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + assert_eq!(plugin.name(), "example_sso"); + assert_eq!(plugin.supported_methods(), &["v3examplesso"]); + assert_eq!(plugin.api_version(), (3, 0)); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_scheme_is_rejected_before_any_prompt() -> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + let identity_url = url::Url::parse("https://keystone.example.test/v3")?; + let http_client = reqwest::Client::new(); + + let mut values: HashMap = HashMap::new(); + values.insert("mode".to_string(), SecretString::from("bad_scheme")); + + // If this reached the interactive confirmation prompt it would hang or + // error on this environment's non-interactive stdin; a bounded timeout + // makes that failure mode loud (test failure) instead of a silent hang. + let result = tokio::time::timeout( + Duration::from_secs(10), + plugin.auth(&http_client, &identity_url, &values, None, None), + ) + .await + .expect("must not block on a confirmation prompt"); + + let err = result.expect_err("a non-https redirect must be rejected"); + assert!( + err.to_string().to_lowercase().contains("https") + || err.to_string().to_lowercase().contains("scheme"), + "unexpected error: {err}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn undeclared_redirect_host_is_rejected_before_any_prompt() +-> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + let identity_url = url::Url::parse("https://keystone.example.test/v3")?; + let http_client = reqwest::Client::new(); + + let mut values: HashMap = HashMap::new(); + values.insert("mode".to_string(), SecretString::from("bad_host")); + + let result = tokio::time::timeout( + Duration::from_secs(10), + plugin.auth(&http_client, &identity_url, &values, None, None), + ) + .await + .expect("must not block on a confirmation prompt"); + + let err = result.expect_err("an undeclared redirect host must be rejected outright"); + assert!( + err.to_string().to_lowercase().contains("redirect host"), + "unexpected error: {err}" + ); + Ok(()) +} + +#[test] +fn sso_guest_round_trip_is_well_formed() -> Result<(), Box> { + let mut plugin = raw_plugin()?; + + let build_request = serde_json::json!({ + "identity_url": "https://keystone.example.test/v3", + "callback_url": "http://127.0.0.1:54321/callback?state=abc123", + "values": {}, + "scope": null, + "hints": null, + }) + .to_string(); + let build_output: String = plugin.call("sso_build_request", build_request.as_str())?; + let build: serde_json::Value = serde_json::from_str(&build_output)?; + + let url = build["url"].as_str().ok_or("missing url")?; + assert!(url.starts_with("https://idp.example.test/authorize")); + assert!(url.contains("redirect_uri=")); + assert_eq!(build["redirect_host"].as_str(), Some("127.0.0.1:54321")); + + let callback_request = serde_json::json!({"params": {"token": "guest-token"}}).to_string(); + let callback_output: String = plugin.call("sso_parse_callback", callback_request.as_str())?; + let callback: serde_json::Value = serde_json::from_str(&callback_output)?; + assert_eq!(callback["ok"]["token"].as_str(), Some("guest-token")); + + let empty_callback = serde_json::json!({"params": {}}).to_string(); + let empty_output: String = plugin.call("sso_parse_callback", empty_callback.as_str())?; + let empty: serde_json::Value = serde_json::from_str(&empty_output)?; + assert!(empty.get("error").is_some()); + + Ok(()) +} + +/// The exact primitive `auth_via_sso` waits on: a forged `state` never +/// satisfies the callback wait, and the real one does. Covered in depth in +/// `openstack-sdk-websso-host`'s own test suite; repeated here narrowly to +/// document that the SSO ABI flavor's anti-CSRF protection is this same +/// host-owned primitive, not something plugin-wasm reimplements. +#[tokio::test] +async fn callback_server_rejects_forged_state() -> Result<(), Box> { + let server = CallbackServer::bind(None).await?; + let mut forged = server.callback_url().clone(); + forged.set_query(Some("state=forged")); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + let client = reqwest::Client::new(); + let forged_resp = client + .post(forged.as_str()) + .form(&[("token", "attacker-token")]) + .send() + .await?; + assert_eq!(forged_resp.status(), reqwest::StatusCode::FORBIDDEN); + + // wait is still pending: cancel it by letting it time out quickly is + // unnecessary here since we don't send the real callback in this test — + // dropping the task is enough, nothing else depends on its outcome. + wait.abort(); + Ok(()) +} diff --git a/sdk/plugin-wasm/trust/fulcio_intermediate.pem b/sdk/plugin-wasm/trust/fulcio_intermediate.pem new file mode 100644 index 000000000..35de9d626 --- /dev/null +++ b/sdk/plugin-wasm/trust/fulcio_intermediate.pem @@ -0,0 +1,19 @@ +# Vendored Sigstore public-good-instance Fulcio intermediate CA certificate. +# Source: https://raw.githubusercontent.com/sigstore/root-signing/main/targets/fulcio_intermediate_v1.crt.pem +# Fetched: 2026-08-11 +# Point-in-time copy pinned for offline, dependency-free chain verification; +# refresh by re-running the same fetch against the URL above. +-----BEGIN CERTIFICATE----- +MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 +7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS +0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB +BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp +KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI +zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR +nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP +mygUY7Ii2zbdCdliiow= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/sdk/plugin-wasm/trust/fulcio_root.pem b/sdk/plugin-wasm/trust/fulcio_root.pem new file mode 100644 index 000000000..825b19b83 --- /dev/null +++ b/sdk/plugin-wasm/trust/fulcio_root.pem @@ -0,0 +1,18 @@ +# Vendored Sigstore public-good-instance Fulcio root CA certificate. +# Source: https://raw.githubusercontent.com/sigstore/root-signing/main/targets/fulcio_v1.crt.pem +# Fetched: 2026-08-11 +# Point-in-time copy pinned for offline, dependency-free chain verification; +# refresh by re-running the same fetch against the URL above. +-----BEGIN CERTIFICATE----- +MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 +XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex +X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j +YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY +wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ +KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM +WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 +TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/sdk/websso-host/Cargo.toml b/sdk/websso-host/Cargo.toml new file mode 100644 index 000000000..14a7f4217 --- /dev/null +++ b/sdk/websso-host/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "openstack-sdk-websso-host" +description = "Shared browser-based SSO callback host service for OpenStack SDK auth plugins" +version = "0.1.0" +keywords = ["api", "openstack"] +categories = ["api-bindings"] +authors = ["Artem Goncharov (gtema)"] +rust-version.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +bytes.workspace = true +form_urlencoded.workspace = true +http.workspace = true +http-body-util.workspace = true +hyper = { workspace = true, features = ["server", "http1"] } +hyper-util = { workspace = true, features = ["tokio"] } +open.workspace = true +ring.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["net", "rt", "sync", "time", "signal"] } +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +reqwest = { workspace = true, features = ["form"] } + +[lints] +workspace = true diff --git a/sdk/websso-host/src/lib.rs b/sdk/websso-host/src/lib.rs new file mode 100644 index 000000000..9c26bf870 --- /dev/null +++ b/sdk/websso-host/src/lib.rs @@ -0,0 +1,429 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Shared browser-based SSO callback host service. +//! +//! Both the native WebSSO auth plugin (`openstack-sdk-auth-websso`) and WASM +//! SSO auth plugins (the SSO ABI flavor of +//! `openstack_sdk_plugin_wasm::plugin::WasmAuthPlugin`) need the same three +//! host-controlled primitives: +//! +//! - bind a local callback listener and hand out its URL, +//! - generate and validate an anti-CSRF `state` token embedded in that URL, +//! - open the user's browser, optionally enforcing `https://`. +//! +//! Centralizing them here means the security-sensitive parts — the CSRF +//! check and the callback listener itself — are implemented once. A WASM +//! guest never gets a socket or a browser-opening capability of its own: it +//! only ever sees the already-bound callback URL as an input string and +//! hands back already-received callback data as an input string, both pure +//! JSON round trips through the host. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode, body::Incoming as IncomingBody}; +use hyper_util::rt::TokioIo; +use ring::rand::{SecureRandom, SystemRandom}; +use thiserror::Error; +use tokio::net::TcpListener; +use tracing::{info, warn}; +use url::Url; + +const CALLBACK_PATH: &str = "/callback"; +const STATE_PARAM: &str = "state"; +const CALLBACK_PAGE: &str = include_str!("../static/callback.html"); +/// Number of random bytes used for the anti-CSRF `state` token (256 bits). +const STATE_BYTES: usize = 32; + +/// Errors from the shared WebSSO/SSO host service. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum WebssoHostError { + /// Building an HTTP response failed. + #[error("http server error: {}", source)] + Http { + /// The error source. + #[from] + source: http::Error, + }, + + /// Hyper server error. + #[error("hyper (http server) error: {}", source)] + Hyper { + /// The error source. + #[from] + source: hyper::Error, + }, + + /// IO error binding the listener or opening the browser. + #[error("`IO` error: {}", source)] + Io { + /// The error source. + #[from] + source: std::io::Error, + }, + + /// The URL a caller tried to open required `https://` and didn't have + /// it. + #[error("refusing to open a non-https URL (scheme was `{scheme}`)")] + InsecureScheme { + /// The scheme the URL actually had. + scheme: String, + }, + + /// No callback (matching or not) arrived before the deadline. + #[error("timed out after {:?} waiting for the SSO callback", .0)] + Timeout(Duration), + + /// The wait was cancelled (e.g. Ctrl-C). + #[error("SSO callback wait was cancelled")] + Cancelled, + + /// The secure random generator failed. + #[error("failed to generate a secure random `state` token")] + Random, + + /// Internal lock was poisoned. + #[error("internal error: poisoned lock: {}", context)] + PoisonedLock { + /// Context describing which lock was poisoned. + context: String, + }, + + /// Building the callback URL from the bound listener's address failed. + #[error("failed to build the callback URL: {}", source)] + UrlParse { + /// The error source. + #[from] + source: url::ParseError, + }, +} + +/// Whether [`open_browser`] should refuse to open a non-`https://` URL. +/// +/// The native Keystone WebSSO flow may reasonably point at a plain `http://` +/// identity endpoint in a local/dev deployment, so it keeps this `false` +/// (unchanged, pre-existing behavior). WASM SSO plugins have no such +/// grandfathered use case and MUST set this `true`, per the SSO ABI's +/// fail-closed design. +#[derive(Clone, Copy, Debug, Default)] +pub struct BrowserOpenPolicy { + /// Require the URL's scheme to be `https`. + pub require_https: bool, +} + +/// Open `url` in the user's default browser. +/// +/// Refuses with [`WebssoHostError::InsecureScheme`] if `policy.require_https` +/// is set and `url`'s scheme isn't `https`. This check happens before any +/// attempt to actually launch a browser. +pub fn open_browser(url: &Url, policy: BrowserOpenPolicy) -> Result<(), WebssoHostError> { + if policy.require_https && url.scheme() != "https" { + return Err(WebssoHostError::InsecureScheme { + scheme: url.scheme().to_string(), + }); + } + info!("Opening browser at {:?}", url.as_str()); + open::that(url.as_str())?; + Ok(()) +} + +/// A bound local callback listener with a fresh anti-CSRF `state` token +/// already embedded in its URL. +pub struct CallbackServer { + listener: TcpListener, + state: String, + callback_url: Url, +} + +impl CallbackServer { + /// Bind a local callback listener on `port` (or an OS-assigned ephemeral + /// port if `None`), generating a fresh `state` token and embedding it in + /// the returned callback URL's query string. + pub async fn bind(port: Option) -> Result { + let listener = TcpListener::bind(("127.0.0.1", port.unwrap_or(0))).await?; + let addr = listener.local_addr()?; + let state = generate_state()?; + let mut callback_url = Url::parse(&format!("http://{addr}{CALLBACK_PATH}"))?; + callback_url + .query_pairs_mut() + .append_pair(STATE_PARAM, &state); + Ok(Self { + listener, + state, + callback_url, + }) + } + + /// The full callback URL, including the embedded `state` token, that a + /// caller should direct the identity provider (or SSO plugin) to POST + /// back to. + pub fn callback_url(&self) -> &Url { + &self.callback_url + } + + /// The `host:port` authority of the callback URL — what a plugin's + /// self-declared redirect target must match. + pub fn redirect_host(&self) -> String { + match self.callback_url.port() { + Some(port) => format!("{}:{port}", self.callback_url.host_str().unwrap_or("")), + None => self.callback_url.host_str().unwrap_or("").to_string(), + } + } + + /// Wait (up to `timeout`, cancellable with Ctrl-C) for a single POST to + /// the callback URL whose `state` parameter matches the one embedded in + /// [`Self::callback_url`]. + /// + /// The `state` token is read from the callback URL's own query string + /// (not the POST body — the identity provider/plugin only ever POSTs + /// back to the exact callback URL it was given, so the query string + /// round-trips unchanged regardless of what body fields it sends). Any + /// request with a missing or mismatched `state` is rejected with `403` + /// and does **not** satisfy the wait — a forged callback (state + /// omitted, guessed, or replayed from a previous run) can never + /// complete the flow; the server keeps waiting for the real one until + /// the timeout. Returns every form-encoded parameter from the accepted + /// request's POST body. + pub async fn wait_for_callback( + self, + timeout: Duration, + ) -> Result, WebssoHostError> { + tokio::select! { + res = self.wait_for_callback_inner(timeout) => res, + _ = tokio::signal::ctrl_c() => Err(WebssoHostError::Cancelled), + } + } + + async fn wait_for_callback_inner( + self, + timeout: Duration, + ) -> Result, WebssoHostError> { + let Self { + listener, state, .. + } = self; + let result: Arc>>> = Arc::new(Mutex::new(None)); + + loop { + tokio::select! { + accepted = listener.accept() => { + let (stream, _addr) = accepted?; + let io = TokioIo::new(stream); + let state = state.clone(); + let conn_result = result.clone(); + let service = service_fn(move |req| { + handle_callback(req, state.clone(), conn_result.clone()) + }); + // Single-shot server: force `Connection: close` so + // `serve_connection` returns as soon as the response is + // sent, instead of idling on a keep-alive connection + // until the client's pool eventually times it out. + if let Err(err) = http1::Builder::new() + .keep_alive(false) + .serve_connection(io, service) + .await + { + warn!("failed to serve SSO callback connection: {err:?}"); + } + if result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?.is_some() { + break; + } + } + _ = tokio::time::sleep(timeout) => { + return Err(WebssoHostError::Timeout(timeout)); + } + } + } + + let guard = result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?; + guard + .clone() + .ok_or(WebssoHostError::Timeout(Duration::default())) + } +} + +async fn handle_callback( + req: Request, + expected_state: String, + result: Arc>>>, +) -> Result>, WebssoHostError> { + match (req.method(), req.uri().path()) { + (&Method::POST, CALLBACK_PATH) => { + // The `state` token was embedded in the callback URL's query + // string (see `CallbackServer::bind`), not the POST body: the + // identity provider (or SSO plugin) is only ever told to POST + // back to that exact URL, so the query string round-trips + // unchanged regardless of what body fields the provider sends. + let received_state = req.uri().query().and_then(|q| { + form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == STATE_PARAM) + .map(|(_, v)| v.into_owned()) + }); + let b = req.collect().await?.to_bytes(); + let params: HashMap = + form_urlencoded::parse(b.as_ref()).into_owned().collect(); + let state_ok = received_state + .as_deref() + .map(|s| constant_time_eq(s.as_bytes(), expected_state.as_bytes())) + .unwrap_or(false); + if !state_ok { + warn!( + "rejected SSO callback with a missing/invalid `state` parameter (possible forged or replayed callback)" + ); + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Empty::::new().boxed())?); + } + + let mut guard = result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?; + *guard = Some(params); + drop(guard); + + Ok(Response::builder().body(Full::new(Bytes::from(CALLBACK_PAGE)).boxed())?) + } + _ => Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Empty::::new().boxed())?), + } +} + +fn generate_state() -> Result { + let rng = SystemRandom::new(); + let mut bytes = [0u8; STATE_BYTES]; + rng.fill(&mut bytes).map_err(|_| WebssoHostError::Random)?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// Constant-time byte comparison, used for the `state` check so a mismatch +/// can't be timed to leak how many leading bytes matched. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn accepts_matching_state_and_strips_it() { + let server = CallbackServer::bind(None).await.expect("bind"); + let callback_url = server.callback_url().clone(); + assert_eq!(callback_url.path(), CALLBACK_PATH); + let redirect_host = server.redirect_host(); + assert!(!redirect_host.is_empty()); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + let client = reqwest::Client::new(); + let resp = client + .post(callback_url.as_str()) + .form(&[("token", "secret-token"), ("extra", "1")]) + .send() + .await + .expect("post callback"); + assert!(resp.status().is_success()); + + let params = wait.await.expect("join").expect("wait_for_callback"); + assert_eq!( + params.get("token").map(String::as_str), + Some("secret-token") + ); + assert_eq!(params.get("extra").map(String::as_str), Some("1")); + assert!(!params.contains_key(STATE_PARAM)); + } + + #[tokio::test] + async fn rejects_forged_state_then_accepts_the_real_callback() { + let server = CallbackServer::bind(None).await.expect("bind"); + let mut callback_url = server.callback_url().clone(); + let real_query = callback_url.query().unwrap_or("").to_string(); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + // A forged callback with a guessed/omitted state must not satisfy + // the wait. + callback_url.set_query(Some("state=forged-state-value")); + let client = reqwest::Client::new(); + let forged_resp = client + .post(callback_url.as_str()) + .form(&[("token", "attacker-token")]) + .send() + .await + .expect("post forged callback"); + assert_eq!(forged_resp.status(), reqwest::StatusCode::FORBIDDEN); + + // The real callback, with the correct state, does. + callback_url.set_query(Some(&real_query)); + let real_resp = client + .post(callback_url.as_str()) + .form(&[("token", "real-token")]) + .send() + .await + .expect("post real callback"); + assert!(real_resp.status().is_success()); + + let params = wait.await.expect("join").expect("wait_for_callback"); + assert_eq!(params.get("token").map(String::as_str), Some("real-token")); + } + + #[tokio::test] + async fn times_out_when_nothing_arrives() { + let server = CallbackServer::bind(None).await.expect("bind"); + let err = server + .wait_for_callback(Duration::from_millis(50)) + .await + .expect_err("should time out"); + assert!(matches!(err, WebssoHostError::Timeout(_))); + } + + #[test] + fn open_browser_refuses_non_https_when_required() { + let url = Url::parse("http://example.com").expect("valid url"); + let err = open_browser( + &url, + BrowserOpenPolicy { + require_https: true, + }, + ) + .expect_err("should refuse http"); + assert!(matches!(err, WebssoHostError::InsecureScheme { .. })); + } + + #[test] + fn constant_time_eq_matches_and_rejects() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"ab")); + } +} diff --git a/sdk/auth-websso/static/callback.html b/sdk/websso-host/static/callback.html similarity index 100% rename from sdk/auth-websso/static/callback.html rename to sdk/websso-host/static/callback.html diff --git a/typos.toml b/typos.toml index 28dd792cf..44e07b055 100644 --- a/typos.toml +++ b/typos.toml @@ -28,7 +28,7 @@ ratatui = "ratatui" [type.rust] extend-glob = [] extend-ignore-identifiers-re = ["consts"] -extend-ignore-words-re = ["udid"] +extend-ignore-words-re = ["udid", "abd"] extend-ignore-re = [] [type.py]