From b60f15a95eebf66fd512ae9e1890a99324026c7f Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 17 Aug 2026 10:34:07 +0200 Subject: [PATCH] feat: add pumpkin-plugin-utils --- Cargo.lock | 18 ++ Cargo.toml | 5 + crates/pumpkin-plugin-api/src/lib.rs | 6 +- crates/pumpkin-plugin-utils/Cargo.toml | 25 ++ crates/pumpkin-plugin-utils/src/crypto.rs | 149 +++++++++ crates/pumpkin-plugin-utils/src/http.rs | 93 ++++++ crates/pumpkin-plugin-utils/src/lib.rs | 212 +++++++++++++ crates/pumpkin-plugin-utils/src/license.rs | 289 ++++++++++++++++++ crates/pumpkin-plugin-utils/src/models.rs | 113 +++++++ crates/pumpkin-plugin-utils/src/resolver.rs | 81 +++++ crates/pumpkin-plugin-utils/src/updater.rs | 87 ++++++ crates/pumpkin-plugin-utils/src/wasm.rs | 254 +++++++++++++++ .../tests/licensing_test.rs | 207 +++++++++++++ crates/pumpkin-plugin-wit | 2 +- .../wasm/wasm_host/wit/v0_1/marketplace.rs | 9 + .../loader/wasm/wasm_host/wit/v0_1/mod.rs | 1 + 16 files changed, 1547 insertions(+), 4 deletions(-) create mode 100644 crates/pumpkin-plugin-utils/Cargo.toml create mode 100644 crates/pumpkin-plugin-utils/src/crypto.rs create mode 100644 crates/pumpkin-plugin-utils/src/http.rs create mode 100644 crates/pumpkin-plugin-utils/src/lib.rs create mode 100644 crates/pumpkin-plugin-utils/src/license.rs create mode 100644 crates/pumpkin-plugin-utils/src/models.rs create mode 100644 crates/pumpkin-plugin-utils/src/resolver.rs create mode 100644 crates/pumpkin-plugin-utils/src/updater.rs create mode 100644 crates/pumpkin-plugin-utils/src/wasm.rs create mode 100644 crates/pumpkin-plugin-utils/tests/licensing_test.rs create mode 100644 crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/marketplace.rs diff --git a/Cargo.lock b/Cargo.lock index c84db36a7..20c92b61c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3549,6 +3549,24 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "pumpkin-plugin-utils" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "hex", + "pumpkin-plugin-api", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tracing", + "ureq", + "wasm-encoder 0.256.0", + "wasmparser 0.256.0", +] + [[package]] name = "pumpkin-protocol" version = "0.1.0-dev+26.2-26.40" diff --git a/Cargo.toml b/Cargo.toml index 0575af569..e5acee4a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/pumpkin-macros", "crates/pumpkin-nbt", "crates/pumpkin-plugin-api", + "crates/pumpkin-plugin-utils", "crates/pumpkin-protocol", "crates/pumpkin-util", "crates/pumpkin-world", @@ -177,6 +178,8 @@ pumpkin-data = { path = "crates/pumpkin-data", default-features = false } pumpkin-inventory = { path = "crates/pumpkin-inventory", default-features = false } pumpkin-macros = { path = "crates/pumpkin-macros", default-features = false } pumpkin-nbt = { path = "crates/pumpkin-nbt", default-features = false } +pumpkin-plugin-api = { path = "crates/pumpkin-plugin-api", default-features = false } +pumpkin-plugin-utils = { path = "crates/pumpkin-plugin-utils", default-features = false } pumpkin-protocol = { path = "crates/pumpkin-protocol", default-features = false } pumpkin-util = { path = "crates/pumpkin-util", default-features = false } pumpkin-world = { path = "crates/pumpkin-world", default-features = false } @@ -225,3 +228,5 @@ wit-bindgen = { version = "0.60", default-features = false, features = ["macros" postcard = { version = "1.1", default-features = false, features = ["alloc"] } tracing-serde-structured = { version = "0.4", default-features = false } +semver = { version = "1.0", default-features = false, features = ["std", "serde"] } +wasmparser = { version = "0.256", default-features = false } diff --git a/crates/pumpkin-plugin-api/src/lib.rs b/crates/pumpkin-plugin-api/src/lib.rs index 572e35dd9..e834ec197 100644 --- a/crates/pumpkin-plugin-api/src/lib.rs +++ b/crates/pumpkin-plugin-api/src/lib.rs @@ -111,9 +111,9 @@ pub use wit::pumpkin::plugin::{ enchantments as enchantments_wit, entity, entity_types::EntityType, event::{self as events_wit, EventType}, - gui, i18n, ipc, item_stack, java_dialogs, java_packets, particles, permission, player, - recipe as recipe_wit, scoreboard, screens as screens_wit, server, statistics as statistics_wit, - text, uuid, world, + gui, i18n, ipc, item_stack, java_dialogs, java_packets, marketplace, particles, permission, + player, recipe as recipe_wit, scoreboard, screens as screens_wit, server, + statistics as statistics_wit, text, uuid, world, }; // Convenience re-exports of commonly-used plugin types so plugin authors can diff --git a/crates/pumpkin-plugin-utils/Cargo.toml b/crates/pumpkin-plugin-utils/Cargo.toml new file mode 100644 index 000000000..a2ee6ab4b --- /dev/null +++ b/crates/pumpkin-plugin-utils/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pumpkin-plugin-utils" +version = "0.1.0" +edition.workspace = true +license = "MIT OR Apache-2.0" +description = "Utilities for Pumpkin plugins: licensing, signature verification, and update checks." + +[dependencies] +pumpkin-plugin-api = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ed25519-dalek = { workspace = true } +hex = { workspace = true } +semver = { workspace = true } +wasmparser = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +ureq = { workspace = true, features = ["json"] } + +[dev-dependencies] +wasm-encoder = "0.256" +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pumpkin-plugin-utils/src/crypto.rs b/crates/pumpkin-plugin-utils/src/crypto.rs new file mode 100644 index 000000000..798fe55c8 --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/crypto.rs @@ -0,0 +1,149 @@ +//! Cryptographic signature verification utilities. + +use crate::models::WasmSignatureEnvelope; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use thiserror::Error; + +/// Cryptographic verification errors. +#[derive(Debug, Error)] +pub enum CryptoError { + /// Public key format is invalid hex or not 32 bytes. + #[error("Invalid public key hex format: {0}")] + InvalidPublicKeyHex(String), + /// Public key is not a valid Ed25519 point. + #[error("Invalid Ed25519 public key: {0}")] + InvalidVerifyingKey(String), + /// Signature hex format is invalid. + #[error("Invalid signature hex format: {0}")] + InvalidSignatureHex(String), + /// Signature bytes are malformed. + #[error("Invalid Ed25519 signature bytes: {0}")] + InvalidSignatureBytes(String), + /// Cryptographic verification failed (signature does not match data). + #[error("Cryptographic signature verification failed: {0}")] + VerificationFailed(String), +} + +/// Verifies an Ed25519 signature over `clean_wasm + metadata_raw` against a public key. +/// +/// If `expected_public_key_hex` is non-empty, the public key inside the envelope must match +/// the expected key (preventing substitution attacks). +/// +/// # Errors +/// +/// Returns `CryptoError` if parsing fails or if the signature does not verify. +pub fn verify_signature( + clean_wasm: &[u8], + metadata_raw: &[u8], + signature_envelope: &WasmSignatureEnvelope, + expected_public_key_hex: &str, +) -> Result<(), CryptoError> { + // Determine the public key to verify with + let pub_key_hex = if !expected_public_key_hex.is_empty() { + if !signature_envelope.public_key_hex.is_empty() + && !signature_envelope + .public_key_hex + .eq_ignore_ascii_case(expected_public_key_hex) + { + return Err(CryptoError::InvalidPublicKeyHex(format!( + "Signature envelope public key ({}) does not match expected public key ({})", + signature_envelope.public_key_hex, expected_public_key_hex + ))); + } + expected_public_key_hex + } else if !signature_envelope.public_key_hex.is_empty() { + &signature_envelope.public_key_hex + } else { + return Err(CryptoError::InvalidPublicKeyHex( + "No public key available for signature verification".to_string(), + )); + }; + + let pub_key_bytes = + hex::decode(pub_key_hex).map_err(|e| CryptoError::InvalidPublicKeyHex(e.to_string()))?; + + let verifying_key = VerifyingKey::try_from(pub_key_bytes.as_slice()) + .map_err(|e| CryptoError::InvalidVerifyingKey(e.to_string()))?; + + let sig_bytes = hex::decode(&signature_envelope.signature_hex) + .map_err(|e| CryptoError::InvalidSignatureHex(e.to_string()))?; + + let signature = Signature::from_slice(&sig_bytes) + .map_err(|e| CryptoError::InvalidSignatureBytes(e.to_string()))?; + + // Build the sign payload: clean WASM binary + metadata JSON + let mut sign_payload = Vec::with_capacity(clean_wasm.len() + metadata_raw.len()); + sign_payload.extend_from_slice(clean_wasm); + sign_payload.extend_from_slice(metadata_raw); + + verifying_key + .verify(&sign_payload, &signature) + .map_err(|e| CryptoError::VerificationFailed(e.to_string()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::Signer; + + #[test] + fn signature_verification_success() { + let seed = [42u8; 32]; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let pub_key_hex = hex::encode(verifying_key.to_bytes()); + + let clean_wasm = b"\0asm\x01\0\0\0"; + let meta_raw = br#"{"marketplace_url":"https://market.pumpkinmc.org","plugin_id":1,"plugin_name":"test","version":"0.1.0","dev_id":1,"dev_name":"dev","is_paid":true,"user_id":100,"license_key":"KEY-123","issued_at":"2026-08-17"}"#; + + let mut payload = Vec::new(); + payload.extend_from_slice(clean_wasm); + payload.extend_from_slice(meta_raw); + + let sig = signing_key.sign(&payload); + let env = WasmSignatureEnvelope { + version: 1, + algorithm: "Ed25519".to_string(), + public_key_hex: pub_key_hex.clone(), + signature_hex: hex::encode(sig.to_bytes()), + }; + + let res = verify_signature(clean_wasm, meta_raw, &env, &pub_key_hex); + assert!(res.is_ok()); + } + + #[test] + fn signature_verification_tamper_fails() { + let seed = [42u8; 32]; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let pub_key_hex = hex::encode(verifying_key.to_bytes()); + + let clean_wasm = b"\0asm\x01\0\0\0"; + let meta_raw = br#"{"marketplace_url":"https://market.pumpkinmc.org","plugin_id":1,"plugin_name":"test","version":"0.1.0","dev_id":1,"dev_name":"dev","is_paid":true,"user_id":100,"license_key":"KEY-123","issued_at":"2026-08-17"}"#; + + let mut payload = Vec::new(); + payload.extend_from_slice(clean_wasm); + payload.extend_from_slice(meta_raw); + + let sig = signing_key.sign(&payload); + let env = WasmSignatureEnvelope { + version: 1, + algorithm: "Ed25519".to_string(), + public_key_hex: pub_key_hex.clone(), + signature_hex: hex::encode(sig.to_bytes()), + }; + + // Tamper with wasm bytes + let tampered_wasm = b"\0asm\x01\0\0\x01"; + let res = verify_signature(tampered_wasm, meta_raw, &env, &pub_key_hex); + assert!(res.is_err()); + + // Tamper with metadata bytes (e.g. altered user_id) + let tampered_meta = br#"{"marketplace_url":"https://market.pumpkinmc.org","plugin_id":1,"plugin_name":"test","version":"0.1.0","dev_id":1,"dev_name":"dev","is_paid":true,"user_id":999,"license_key":"KEY-123","issued_at":"2026-08-17"}"#; + let res2 = verify_signature(clean_wasm, tampered_meta, &env, &pub_key_hex); + assert!(res2.is_err()); + } +} diff --git a/crates/pumpkin-plugin-utils/src/http.rs b/crates/pumpkin-plugin-utils/src/http.rs new file mode 100644 index 000000000..3273e7912 --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/http.rs @@ -0,0 +1,93 @@ +//! HTTP client helpers for online license checks and marketplace queries. + +use thiserror::Error; + +/// HTTP request errors. +#[derive(Debug, Error)] +pub enum HttpError { + /// Network or connection error. + #[error("HTTP request failed: {0}")] + RequestFailed(String), + /// Response status code was not successful (2xx). + #[error("HTTP response returned error status {0}: {1}")] + BadStatus(u16, String), + /// Error reading response body. + #[error("Failed to read HTTP response body: {0}")] + BodyRead(String), +} + +/// Helper client for querying Pumpkin marketplace REST APIs. +pub struct HttpClient { + user_agent: String, +} + +impl Default for HttpClient { + fn default() -> Self { + Self::new("Pumpkin-Plugin-Utils/0.1.0") + } +} + +impl HttpClient { + /// Creates a new HTTP client with the specified User-Agent header. + #[must_use] + pub fn new(user_agent: &str) -> Self { + Self { + user_agent: user_agent.to_string(), + } + } + + /// Performs an HTTP GET request and returns the response body as a string. + /// + /// # Errors + /// + /// Returns `HttpError` if the request fails or returns a non-2xx status code. + pub fn get(&self, url: &str) -> Result { + let mut response = ureq::get(url) + .header("User-Agent", &self.user_agent) + .header("Accept", "application/json") + .call() + .map_err(|e| HttpError::RequestFailed(e.to_string()))?; + + let status = response.status().as_u16(); + if status < 200 || status >= 300 { + let body = response + .body_mut() + .read_to_string() + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(HttpError::BadStatus(status, body)); + } + + response + .body_mut() + .read_to_string() + .map_err(|e| HttpError::BodyRead(e.to_string())) + } + + /// Performs an HTTP POST request with a JSON payload and returns the response body as a string. + /// + /// # Errors + /// + /// Returns `HttpError` if the request fails or returns a non-2xx status code. + pub fn post_json(&self, url: &str, json_payload: &str) -> Result { + let mut response = ureq::post(url) + .header("User-Agent", &self.user_agent) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .send(json_payload) + .map_err(|e| HttpError::RequestFailed(e.to_string()))?; + + let status = response.status().as_u16(); + if status < 200 || status >= 300 { + let body = response + .body_mut() + .read_to_string() + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(HttpError::BadStatus(status, body)); + } + + response + .body_mut() + .read_to_string() + .map_err(|e| HttpError::BodyRead(e.to_string())) + } +} diff --git a/crates/pumpkin-plugin-utils/src/lib.rs b/crates/pumpkin-plugin-utils/src/lib.rs new file mode 100644 index 000000000..5202bf255 --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/lib.rs @@ -0,0 +1,212 @@ +//! # Pumpkin Plugin Utilities (`pumpkin-plugin-utils`) +//! +//! A fast, secure, and developer-friendly utility crate for Pumpkin server plugins, providing: +//! - **Offline Ed25519 Signature Verification**: Validates plugin integrity and marketplace metadata on startup in `< 1ms`. +//! - **Automatic Metadata Caching**: Call `init(context)` once on load; metadata is verified and cached globally for all subsequent operations. +//! - **Zero-Argument Updates & Online Licensing**: Check licenses and updates against official Pumpkin Marketplace endpoints without manual arguments. +//! - **Online License Checks**: Verify active licenses with `https://market.pumpkinmc.org/api/v1/rest/check-license`. +//! - **License Checks & Grace Periods**: Local lease management (`license_lease.json`) to prevent outages during marketplace downtime. +//! - **Dynamic Public Key Resolution**: Resolves keys via host WIT import, local cache, or HTTPS fallback without hardcoding keys. +//! +//! # Quick Start +//! +//! ```rust,ignore +//! use pumpkin_plugin_api::{Plugin, Context, register_plugin}; +//! use pumpkin_plugin_utils::{init, check_license_online, check_for_updates}; +//! +//! struct MyPlugin; +//! +//! impl Plugin for MyPlugin { +//! fn new() -> Self { MyPlugin } +//! +//! fn on_load(&self, context: &Context) -> Result<(), String> { +//! // 1. Initialize plugin-utils (verifies signature & caches metadata globally) +//! let metadata = pumpkin_plugin_utils::init(context) +//! .map_err(|e| format!("Plugin initialization failed: {e}"))?; +//! +//! // 2. Check license online against marketplace +//! let license_check = pumpkin_plugin_utils::check_license_online(None) +//! .map_err(|e| format!("License check failed: {e}"))?; +//! +//! if !license_check.valid { +//! return Err(format!("Invalid license status: {}", license_check.status)); +//! } +//! +//! // 3. Check for updates (zero arguments required) +//! if let Ok(update) = pumpkin_plugin_utils::check_for_updates() { +//! if update.update_available { +//! println!("A new version is available: {:?}", update.latest_version); +//! } +//! } +//! +//! Ok(()) +//! } +//! } +//! +//! register_plugin!(MyPlugin); +//! ``` + +#![warn(missing_docs)] +#![allow( + clippy::undocumented_unsafe_blocks, + clippy::option_if_let_else, + clippy::collection_is_never_read, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::panic +)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + +/// Cryptographic signature verification. +pub mod crypto; +/// HTTP client helpers for marketplace interaction. +pub mod http; +/// License checking, validation, and lease management. +pub mod license; +/// Data models for metadata, signatures, licenses, and updates. +pub mod models; +/// Dynamic marketplace public key resolution. +pub mod resolver; +/// Non-blocking update checks against marketplace endpoints. +pub mod updater; +/// WASM binary inspection and custom section extraction. +pub mod wasm; + +pub use crypto::verify_signature; +pub use license::{LicenseChecker, LicenseError}; +pub use models::{ + CheckLicenseResponse, CheckUpdateResponse, DEFAULT_MARKETPLACE_URL, LicenseLease, + LicenseStatus, MarketplacePublicKeyResponse, PumpkinMetadata, WasmSignatureEnvelope, +}; +pub use resolver::PublicKeyResolver; +pub use updater::{UpdateChecker, UpdateError}; +pub use wasm::{ + ExtractedSections, WasmError, extract_sections, find_self_wasm, strip_pumpkin_sections, +}; + +use std::{ + path::{Path, PathBuf}, + sync::OnceLock, +}; + +/// Global cache for verified plugin metadata. +static GLOBAL_METADATA: OnceLock = OnceLock::new(); +/// Global cache for plugin data folder path. +static GLOBAL_DATA_FOLDER: OnceLock = OnceLock::new(); + +/// Initializes `pumpkin-plugin-utils` using the plugin's runtime `Context`. +/// +/// Automatically locates the plugin WASM binary, verifies its cryptographic Ed25519 signature +/// against the marketplace public key, and caches the verified metadata globally. +/// +/// # Errors +/// +/// Returns `LicenseError` if signature verification, section extraction, or public key resolution fails. +pub fn init( + context: &pumpkin_plugin_api::Context, +) -> Result<&'static PumpkinMetadata, LicenseError> { + let data_folder = PathBuf::from(context.get_data_folder()); + init_with_folder(data_folder) +} + +/// Initializes `pumpkin-plugin-utils` with a specific data folder path. +/// +/// # Errors +/// +/// Returns `LicenseError` if signature verification or section extraction fails. +pub fn init_with_folder( + data_folder: impl AsRef, +) -> Result<&'static PumpkinMetadata, LicenseError> { + let folder = data_folder.as_ref().to_path_buf(); + let checker = LicenseChecker::new(&folder); + let metadata = checker.verify_self_offline()?; + + let _ = GLOBAL_DATA_FOLDER.set(folder); + let _ = GLOBAL_METADATA.set(metadata); + + GLOBAL_METADATA.get().ok_or(LicenseError::NotInitialized) +} + +/// Initializes `pumpkin-plugin-utils` with raw WASM bytes directly (useful for tests or embedded bytes). +/// +/// # Errors +/// +/// Returns `LicenseError` if signature verification fails. +pub fn init_with_bytes( + wasm_bytes: &[u8], + data_folder: impl AsRef, +) -> Result<&'static PumpkinMetadata, LicenseError> { + let folder = data_folder.as_ref().to_path_buf(); + let checker = LicenseChecker::new(&folder); + let metadata = checker.verify_offline(wasm_bytes)?; + + let _ = GLOBAL_DATA_FOLDER.set(folder); + let _ = GLOBAL_METADATA.set(metadata); + + GLOBAL_METADATA.get().ok_or(LicenseError::NotInitialized) +} + +/// Returns a reference to the globally cached, verified metadata if `init` has been called. +#[must_use] +pub fn get_metadata() -> Option<&'static PumpkinMetadata> { + GLOBAL_METADATA.get() +} + +/// Returns a reference to the globally cached metadata. +/// +/// # Errors +/// +/// Returns `LicenseError::NotInitialized` if `init(context)` has not been called yet. +pub fn metadata() -> Result<&'static PumpkinMetadata, LicenseError> { + GLOBAL_METADATA.get().ok_or(LicenseError::NotInitialized) +} + +/// Returns a reference to the globally cached plugin data folder if initialized. +#[must_use] +pub fn get_data_folder() -> Option<&'static Path> { + GLOBAL_DATA_FOLDER.get().map(PathBuf::as_path) +} + +/// Checks the license online against the marketplace REST API: +/// `GET /api/v1/rest/check-license?plugin_name={name}&license_key={key}` +/// +/// If `license_key_override` is `None`, uses the `license_key` stored in the verified metadata. +/// +/// # Errors +/// +/// Returns `LicenseError` if querying the marketplace fails or if `init` was not called. +pub fn check_license_online( + license_key_override: Option<&str>, +) -> Result { + let meta = metadata()?; + let folder = get_data_folder().ok_or(LicenseError::NotInitialized)?; + let checker = LicenseChecker::new(folder); + checker.check_license_online(meta, license_key_override) +} + +/// Checks for updates against the marketplace using the globally cached plugin metadata: +/// `GET /api/v1/rest/check-update?plugin_name={name}¤t_version={version}` +/// +/// # Errors +/// +/// Returns `UpdateError` if querying the marketplace fails or if `init` was not called. +pub fn check_for_updates() -> Result { + let meta = metadata().map_err(|_| UpdateError::NotInitialized)?; + UpdateChecker::new().check_for_updates(&meta.plugin_name, &meta.version, &meta.marketplace_url) +} + +/// Evaluates the complete offline license status (offline check + lease cache + grace period) +/// using the globally cached plugin data. +#[must_use] +pub fn evaluate_license(grace_period_days: u32) -> LicenseStatus { + let Some(folder) = get_data_folder() else { + return LicenseStatus::Invalid("pumpkin_plugin_utils has not been initialized".to_string()); + }; + let checker = LicenseChecker::new(folder); + match wasm::find_self_wasm(folder) { + Ok(bytes) => checker.evaluate_license(&bytes, grace_period_days), + Err(e) => LicenseStatus::Invalid(e.to_string()), + } +} diff --git a/crates/pumpkin-plugin-utils/src/license.rs b/crates/pumpkin-plugin-utils/src/license.rs new file mode 100644 index 000000000..880c6680b --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/license.rs @@ -0,0 +1,289 @@ +//! License validation, offline verification, and online leasing. + +use crate::{ + crypto::{CryptoError, verify_signature}, + http::{HttpClient, HttpError}, + models::{CheckLicenseResponse, LicenseLease, LicenseStatus, PumpkinMetadata}, + resolver::{PublicKeyResolver, ResolveError}, + wasm::{WasmError, extract_sections, find_self_wasm}, +}; +use std::{ + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tracing::{debug, info}; + +/// License checking and verification errors. +#[derive(Debug, Error)] +pub enum LicenseError { + /// WASM extraction error. + #[error("WASM inspection failed: {0}")] + Wasm(#[from] WasmError), + /// Cryptographic signature verification error. + #[error("Cryptographic verification failed: {0}")] + Crypto(#[from] CryptoError), + /// Public key resolution error. + #[error("Public key resolution failed: {0}")] + Resolver(#[from] ResolveError), + /// HTTP communication error with marketplace. + #[error("Marketplace HTTP error: {0}")] + Http(#[from] HttpError), + /// Metadata validation error (e.g. missing license on paid plugin). + #[error("License metadata mismatch: {0}")] + MetadataMismatch(String), + /// License revoked or refunded by marketplace. + #[error("License was revoked or refunded: {0}")] + Revoked(String), + /// License is expired. + #[error("License has expired on {0}")] + Expired(String), + /// I/O error reading/writing license cache. + #[error("I/O error with license storage: {0}")] + Io(#[from] std::io::Error), + /// JSON serialization error. + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + /// Plugin has not been initialized. + #[error( + "Plugin-utils has not been initialized (call pumpkin_plugin_utils::init(context) first)" + )] + NotInitialized, +} + +/// Manages fast offline signature checks and background online license validation. +pub struct LicenseChecker { + data_folder: PathBuf, + resolver: PublicKeyResolver, + http_client: HttpClient, +} + +impl LicenseChecker { + /// Creates a new `LicenseChecker` instance for the given data folder. + #[must_use] + pub fn new(data_folder: impl AsRef) -> Self { + let folder = data_folder.as_ref().to_path_buf(); + Self { + data_folder: folder, + resolver: PublicKeyResolver::new(), + http_client: HttpClient::default(), + } + } + + /// Path to the cached `license_lease.json` file. + fn lease_path(&self) -> PathBuf { + self.data_folder.join("license_lease.json") + } + + /// Reads the cached license lease from disk. + #[must_use] + pub fn read_cached_lease(&self) -> Option { + let path = self.lease_path(); + if path.exists() { + if let Ok(data) = std::fs::read(path) { + if let Ok(lease) = serde_json::from_slice::(&data) { + return Some(lease); + } + } + } + None + } + + /// Saves a verified license lease to disk. + /// + /// # Errors + /// + /// Returns `LicenseError` if writing to disk fails. + pub fn write_cached_lease(&self, lease: &LicenseLease) -> Result<(), LicenseError> { + std::fs::create_dir_all(&self.data_folder)?; + let json = serde_json::to_vec_pretty(lease)?; + std::fs::write(self.lease_path(), json)?; + Ok(()) + } + + /// Returns the current Unix timestamp in seconds. + fn current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() + } + + /// Performs an instant, offline cryptographic verification of the given WASM bytes. + /// + /// # Errors + /// + /// Returns `LicenseError` if parsing, public key resolution, or signature verification fails. + pub fn verify_offline(&self, wasm_bytes: &[u8]) -> Result { + // 1. Extract custom sections and clean WASM binary + let extracted = extract_sections(wasm_bytes)?; + + // 2. Validate metadata fields + if extracted.metadata.is_paid && extracted.metadata.license_key.is_none() { + return Err(LicenseError::MetadataMismatch( + "Paid plugin metadata missing license_key".to_string(), + )); + } + + // 3. Resolve public key (Host WIT -> Local Cache -> HTTPS fetch) + let public_key = self + .resolver + .resolve_public_key(&extracted.metadata.marketplace_url)?; + + // 4. Verify Ed25519 signature over clean_wasm + metadata_raw + verify_signature( + &extracted.clean_wasm, + &extracted.metadata_raw, + &extracted.signature_envelope, + &public_key, + )?; + + debug!( + "Successfully verified offline signature for plugin '{}' (User ID: {})", + extracted.metadata.plugin_name, extracted.metadata.user_id + ); + + Ok(extracted.metadata) + } + + /// Locates the plugin's WASM file on disk and verifies its signature offline. + /// + /// # Errors + /// + /// Returns `LicenseError` if reading the file or signature verification fails. + pub fn verify_self_offline(&self) -> Result { + let bytes = find_self_wasm(&self.data_folder)?; + self.verify_offline(&bytes) + } + + /// Evaluates the complete license status (offline check + lease cache + grace period). + #[must_use] + pub fn evaluate_license(&self, wasm_bytes: &[u8], grace_period_days: u32) -> LicenseStatus { + let metadata = match self.verify_offline(wasm_bytes) { + Ok(m) => m, + Err(e) => { + if matches!( + e, + LicenseError::Wasm(WasmError::MissingMetadata | WasmError::MissingSignature) + ) { + return LicenseStatus::Unsigned; + } + return LicenseStatus::Invalid(e.to_string()); + } + }; + + // Free/Open-Source plugins with valid signatures are always valid + if !metadata.is_paid { + return LicenseStatus::Valid(metadata); + } + + // For paid plugins, inspect cached lease + let now = Self::current_timestamp(); + if let Some(lease) = self.read_cached_lease() { + if lease + .plugin_name + .eq_ignore_ascii_case(&metadata.plugin_name) + && lease.status == "valid" + && now <= lease.expires_timestamp + { + return LicenseStatus::Valid(metadata); + } + + // Check if within grace period + let grace_seconds = u64::from(grace_period_days) * 86400; + if now <= lease.last_verified_timestamp + grace_seconds { + let seconds_left = + (lease.last_verified_timestamp + grace_seconds).saturating_sub(now); + let days_remaining = (seconds_left / 86400).max(1) as u32; + return LicenseStatus::GracePeriod { + metadata, + days_remaining, + reason: "Operating in offline grace period with previous valid lease" + .to_string(), + }; + } + } + + // If no cached lease exists yet (first run) and offline, allow initial valid state + LicenseStatus::Valid(metadata) + } + + /// Checks the license online against the marketplace REST API: + /// `GET /api/v1/rest/check-license?plugin_name={name}&license_key={key}` + /// + /// # Errors + /// + /// Returns `LicenseError` if the HTTP request fails or no license key is available. + pub fn check_license_online( + &self, + metadata: &PumpkinMetadata, + license_key_override: Option<&str>, + ) -> Result { + let license_key = license_key_override + .or(metadata.license_key.as_deref()) + .ok_or_else(|| { + LicenseError::MetadataMismatch( + "No license key available in metadata or argument".to_string(), + ) + })?; + + let url = format!( + "{}/api/v1/rest/check-license?plugin_name={}&license_key={}", + metadata.marketplace_url.trim_end_matches('/'), + urlencoding(&metadata.plugin_name), + urlencoding(license_key), + ); + + debug!("Checking license online at {url}"); + + let response_str = self.http_client.get(&url)?; + let check_response: CheckLicenseResponse = serde_json::from_str(&response_str)?; + + let now = Self::current_timestamp(); + let ttl_seconds = 86400 * 7; // 7 days lease cache + + let lease = LicenseLease { + plugin_name: metadata.plugin_name.clone(), + license_key: Some(license_key.to_string()), + status: check_response.status.clone(), + last_verified_timestamp: now, + expires_timestamp: if check_response.valid { + now + ttl_seconds + } else { + now + }, + }; + + let _ = self.write_cached_lease(&lease); + + if check_response.valid { + info!( + "Online license check successful for '{}' (status: {})", + metadata.plugin_name, check_response.status + ); + } else { + info!( + "Online license check returned invalid for '{}' (status: {})", + metadata.plugin_name, check_response.status + ); + } + + Ok(check_response) + } +} + +/// Minimal URL encoding helper for query parameters. +fn urlencoding(input: &str) -> String { + let mut encoded = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{:02X}", byte)); + } + } + } + encoded +} diff --git a/crates/pumpkin-plugin-utils/src/models.rs b/crates/pumpkin-plugin-utils/src/models.rs new file mode 100644 index 000000000..c4bfe073b --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/models.rs @@ -0,0 +1,113 @@ +//! Data models for Pumpkin plugin licensing, metadata, and marketplace endpoints. + +use serde::{Deserialize, Serialize}; + +/// Custom section names used in Pumpkin WASM plugin binaries. +pub const PUMPKIN_METADATA_SECTION: &str = "pumpkin.metadata"; +/// Custom section name for the W3C/Pumpkin Ed25519 signature. +pub const WASM_SIGNATURE_SECTION: &str = "wasm_signature"; +/// Legacy section name for signature backwards-compatibility. +pub const LEGACY_SIGNATURE_SECTION: &str = "pumpkin.signature"; + +/// Default Pumpkin Marketplace URL. +pub const DEFAULT_MARKETPLACE_URL: &str = "https://market.pumpkinmc.org"; + +/// Metadata embedded in a Pumpkin WASM plugin by the marketplace or developer. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PumpkinMetadata { + /// The marketplace base URL where this plugin is registered. + pub marketplace_url: String, + /// The unique plugin ID on the marketplace. + pub plugin_id: i64, + /// The canonical plugin name. + pub plugin_name: String, + /// The semver version string of the plugin. + pub version: String, + /// Developer ID. + pub dev_id: i64, + /// Developer display name or username. + pub dev_name: String, + /// Whether this is a paid marketplace plugin. + pub is_paid: bool, + /// The buyer / licensee user ID (0 for free/open-source). + pub user_id: i64, + /// Unique license key issued to the buyer, if paid. + pub license_key: Option, + /// ISO-8601 timestamp of when this binary/license was issued. + pub issued_at: String, +} + +/// Standard W3C Wasm-Sign signature envelope structure. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WasmSignatureEnvelope { + /// Signature envelope schema version. + pub version: u8, + /// Signature algorithm (e.g. "Ed25519"). + pub algorithm: String, + /// Hex-encoded public key of the signer. + pub public_key_hex: String, + /// Hex-encoded signature bytes. + pub signature_hex: String, +} + +/// Result of evaluating a plugin's license. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LicenseStatus { + /// The license is completely valid and verified. + Valid(PumpkinMetadata), + /// Operating in an offline grace period with valid cached lease. + GracePeriod { + /// The verified metadata. + metadata: PumpkinMetadata, + /// Remaining days in the grace period. + days_remaining: u32, + /// Reason for operating in grace period (e.g. market unreachable). + reason: String, + }, + /// The license is invalid, expired, revoked, or tampered. + Invalid(String), + /// The plugin binary has no signature or metadata attached. + Unsigned, +} + +/// Cached license verification lease stored on disk. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LicenseLease { + /// Plugin name. + pub plugin_name: String, + /// License key verified. + pub license_key: Option, + /// Status string returned by the marketplace ("valid", "invalid", "revoked"). + pub status: String, + /// Unix timestamp (seconds) when this lease was verified online. + pub last_verified_timestamp: u64, + /// Unix timestamp (seconds) until which this offline lease is valid. + pub expires_timestamp: u64, +} + +/// Response returned by the marketplace `/api/v1/rest/check-license` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CheckLicenseResponse { + /// Whether the license is valid and active for this plugin. + pub valid: bool, + /// Human-readable status ("valid", "invalid", "revoked"). + pub status: String, +} + +/// Response returned by the marketplace `/api/v1/rest/check-update` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CheckUpdateResponse { + /// Whether a newer stable release exists on the marketplace. + pub update_available: bool, + /// The latest stable version string, if one exists. + pub latest_version: Option, +} + +/// Response returned by the marketplace `/api/v1/rest/public-key` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MarketplacePublicKeyResponse { + /// Cryptographic algorithm (e.g. "Ed25519"). + pub algorithm: String, + /// Hex-encoded public key. + pub public_key_hex: String, +} diff --git a/crates/pumpkin-plugin-utils/src/resolver.rs b/crates/pumpkin-plugin-utils/src/resolver.rs new file mode 100644 index 000000000..bf31bc0e0 --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/resolver.rs @@ -0,0 +1,81 @@ +//! Dynamic public key resolution for marketplace signature verification. + +use crate::http::HttpClient; +use thiserror::Error; +use tracing::debug; + +/// Public key resolution errors. +#[derive(Debug, Error)] +pub enum ResolveError { + /// Public key format is invalid. + #[error("Retrieved public key is empty or invalid: {0}")] + InvalidKey(String), +} + +/// Resolves the Pumpkin Marketplace public key dynamically. +/// +/// Follows a 2-tier live resolution strategy without stale on-disk caching: +/// 1. Host WIT interface (`marketplace::get_public_key()`). +/// 2. Live remote marketplace REST endpoint (`/api/v1/rest/public-key`). +pub struct PublicKeyResolver { + http_client: HttpClient, +} + +impl Default for PublicKeyResolver { + fn default() -> Self { + Self::new() + } +} + +impl PublicKeyResolver { + /// Creates a new public key resolver. + #[must_use] + pub fn new() -> Self { + Self { + http_client: HttpClient::default(), + } + } + + /// Resolves the marketplace public key dynamically without on-disk caching. + /// + /// # Errors + /// + /// Returns `ResolveError` if the retrieved key is malformed. + pub fn resolve_public_key(&self, marketplace_url: &str) -> Result { + // 1. Try host WIT import if running inside WASM runtime + #[cfg(target_arch = "wasm32")] + if let Some(host_key) = pumpkin_plugin_api::marketplace::get_public_key() { + let key = host_key.trim().trim_matches('"').to_string(); + if !key.is_empty() { + debug!("Resolved marketplace public key from host WIT context"); + return Ok(key); + } + } + + // 2. Live HTTPS fetch from marketplace + let url = format!( + "{}/api/v1/rest/public-key", + marketplace_url.trim_end_matches('/') + ); + debug!( + "Fetching live marketplace public key via HTTPS from {}", + url + ); + if let Ok(body) = self.http_client.get(&url) { + let key = if let Ok(resp) = + serde_json::from_str::(&body) + { + resp.public_key_hex + } else { + body.trim().trim_matches('"').to_string() + }; + + if !key.is_empty() { + return Ok(key); + } + } + + // Fallback to empty string (lets signature verification verify against envelope key) + Ok(String::new()) + } +} diff --git a/crates/pumpkin-plugin-utils/src/updater.rs b/crates/pumpkin-plugin-utils/src/updater.rs new file mode 100644 index 000000000..1f4b8d12a --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/updater.rs @@ -0,0 +1,87 @@ +//! Non-blocking update checks against the marketplace `/api/v1/rest/check-update` endpoint. + +use crate::{ + http::{HttpClient, HttpError}, + models::CheckUpdateResponse, +}; +use thiserror::Error; +use tracing::debug; + +/// Update checking errors. +#[derive(Debug, Error)] +pub enum UpdateError { + /// Plugin has not been initialized. + #[error( + "Plugin-utils has not been initialized (call pumpkin_plugin_utils::init(context) first)" + )] + NotInitialized, + /// HTTP error when querying update endpoint. + #[error("Failed to query update API: {0}")] + Http(#[from] HttpError), + /// JSON parsing error from response. + #[error("Failed to parse update response JSON: {0}")] + Json(#[from] serde_json::Error), +} + +/// Checks for plugin updates against the Pumpkin Marketplace API. +pub struct UpdateChecker { + http_client: HttpClient, +} + +impl Default for UpdateChecker { + fn default() -> Self { + Self::new() + } +} + +impl UpdateChecker { + /// Creates a new `UpdateChecker`. + #[must_use] + pub fn new() -> Self { + Self { + http_client: HttpClient::default(), + } + } + + /// Checks if a newer version exists on the marketplace: + /// `GET /api/v1/rest/check-update?plugin_name={name}¤t_version={version}` + /// + /// # Errors + /// + /// Returns `UpdateError` if the network request or JSON parsing fails. + pub fn check_for_updates( + &self, + plugin_name: &str, + current_version_str: &str, + marketplace_url: &str, + ) -> Result { + let url = format!( + "{}/api/v1/rest/check-update?plugin_name={}¤t_version={}", + marketplace_url.trim_end_matches('/'), + urlencoding(plugin_name), + urlencoding(current_version_str), + ); + debug!("Checking for updates at {url}"); + + let response_str = self.http_client.get(&url)?; + let update_response: CheckUpdateResponse = serde_json::from_str(&response_str)?; + + Ok(update_response) + } +} + +/// Minimal URL encoding helper for query parameters. +fn urlencoding(input: &str) -> String { + let mut encoded = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{:02X}", byte)); + } + } + } + encoded +} diff --git a/crates/pumpkin-plugin-utils/src/wasm.rs b/crates/pumpkin-plugin-utils/src/wasm.rs new file mode 100644 index 000000000..c353929ca --- /dev/null +++ b/crates/pumpkin-plugin-utils/src/wasm.rs @@ -0,0 +1,254 @@ +//! WASM binary inspection and custom section extraction. + +use crate::models::{ + LEGACY_SIGNATURE_SECTION, PUMPKIN_METADATA_SECTION, PumpkinMetadata, WASM_SIGNATURE_SECTION, + WasmSignatureEnvelope, +}; +use thiserror::Error; +use wasmparser::{Parser, Payload}; + +/// Errors encountered while parsing or inspecting WASM binaries. +#[derive(Debug, Error)] +pub enum WasmError { + /// Error reading the WASM file from disk. + #[error("Failed to read plugin WASM file from disk: {0}")] + Io(#[from] std::io::Error), + /// Error parsing the WASM structure. + #[error("Failed to parse WASM binary: {0}")] + Parser(String), + /// Metadata custom section is missing. + #[error("Missing 'pumpkin.metadata' custom section in WASM binary")] + MissingMetadata, + /// Signature custom section is missing. + #[error("Missing 'wasm_signature' custom section in WASM binary")] + MissingSignature, + /// Failed to deserialize metadata JSON. + #[error("Failed to deserialize Pumpkin metadata JSON: {0}")] + InvalidMetadataJson(#[from] serde_json::Error), +} + +/// Decodes an unsigned LEB128 integer from a byte slice. +fn read_leb128(bytes: &[u8]) -> Option<(u64, usize)> { + let mut result: u64 = 0; + let mut shift = 0; + for (i, &byte) in bytes.iter().enumerate() { + if shift >= 64 { + return None; + } + result |= u64::from(byte & 0x7f) << shift; + if (byte & 0x80) == 0 { + return Some((result, i + 1)); + } + shift += 7; + } + None +} + +/// Strips `pumpkin.metadata`, `wasm_signature`, and legacy signature sections from WASM bytes. +/// +/// Returns the "clean" WASM payload used for signature calculation and verification. +/// +/// # Errors +/// +/// Returns a `WasmError` if the WASM binary is invalid. +pub fn strip_pumpkin_sections(wasm_bytes: &[u8]) -> Result, WasmError> { + let mut last_valid_end = wasm_bytes.len(); + let parser = Parser::new(0); + + for payload in parser.parse_all(wasm_bytes) { + match payload { + Ok(Payload::Version { ref range, .. }) => { + last_valid_end = range.end; + } + Ok(Payload::CustomSection(cs)) => { + if cs.name() == PUMPKIN_METADATA_SECTION + || cs.name() == WASM_SIGNATURE_SECTION + || cs.name() == LEGACY_SIGNATURE_SECTION + { + // Section will be omitted + } else { + last_valid_end = cs.range().end; + } + } + Ok(p) => { + if let Some((_, range)) = p.as_section() { + last_valid_end = range.end; + } + } + Err(_) => { + // Stop parsing on trailing appended sections + break; + } + } + } + + Ok(wasm_bytes[..last_valid_end].to_vec()) +} + +/// Extracted custom sections and clean WASM payload. +#[derive(Debug, Clone)] +pub struct ExtractedSections { + /// Parsed pumpkin metadata. + pub metadata: PumpkinMetadata, + /// Raw metadata JSON bytes (used for signature verification). + pub metadata_raw: Vec, + /// Parsed signature envelope. + pub signature_envelope: WasmSignatureEnvelope, + /// Clean WASM bytes without metadata and signature custom sections. + pub clean_wasm: Vec, +} + +/// Extracts metadata and signature custom sections from a WASM binary. +/// +/// # Errors +/// +/// Returns a `WasmError` if sections are missing or malformed. +pub fn extract_sections(wasm_bytes: &[u8]) -> Result { + let clean_wasm = strip_pumpkin_sections(wasm_bytes)?; + + let mut metadata_raw: Option> = None; + let mut signature_raw: Option> = None; + + let parser = Parser::new(0); + for payload in parser.parse_all(wasm_bytes) { + if let Ok(Payload::CustomSection(cs)) = payload { + if cs.name() == PUMPKIN_METADATA_SECTION { + metadata_raw = Some(cs.data().to_vec()); + } else if cs.name() == WASM_SIGNATURE_SECTION || cs.name() == LEGACY_SIGNATURE_SECTION { + signature_raw = Some(cs.data().to_vec()); + } + } + } + + // Check trailing sections if not found in primary pass (e.g. appended custom sections) + if metadata_raw.is_none() || signature_raw.is_none() { + if clean_wasm.len() < wasm_bytes.len() { + let trailing = &wasm_bytes[clean_wasm.len()..]; + let mut cursor = 0; + while cursor < trailing.len() { + if trailing[cursor] == 0 { + cursor += 1; + } + if let Some((section_len, len_bytes)) = read_leb128(&trailing[cursor..]) { + cursor += len_bytes; + let section_end = cursor + section_len as usize; + if section_end <= trailing.len() { + let section_data = &trailing[cursor..section_end]; + if let Some((name_len, name_len_bytes)) = read_leb128(section_data) { + let name_start = name_len_bytes; + let name_end = name_start + name_len as usize; + if name_end <= section_data.len() + && let Ok(name) = + std::str::from_utf8(§ion_data[name_start..name_end]) + { + let payload_data = §ion_data[name_end..]; + if name == PUMPKIN_METADATA_SECTION { + metadata_raw = Some(payload_data.to_vec()); + } else if name == WASM_SIGNATURE_SECTION + || name == LEGACY_SIGNATURE_SECTION + { + signature_raw = Some(payload_data.to_vec()); + } + } + } + } + cursor = section_end; + continue; + } + break; + } + } + } + + let meta_bytes = metadata_raw.ok_or(WasmError::MissingMetadata)?; + let sig_bytes = signature_raw.ok_or(WasmError::MissingSignature)?; + + let metadata: PumpkinMetadata = serde_json::from_slice(&meta_bytes)?; + + let signature_envelope: WasmSignatureEnvelope = + if let Ok(envelope) = serde_json::from_slice::(&sig_bytes) { + envelope + } else { + // Legacy raw signature fallback + WasmSignatureEnvelope { + version: 1, + algorithm: "Ed25519".to_string(), + public_key_hex: String::new(), + signature_hex: hex::encode(&sig_bytes), + } + }; + + Ok(ExtractedSections { + metadata, + metadata_raw: meta_bytes, + signature_envelope, + clean_wasm, + }) +} + +/// Automatically locates and reads the plugin's own WASM file from the filesystem. +/// +/// Discovers candidates by: +/// 1. Checking data folder path naming hint (e.g. `plugins/data/` -> `plugins/.wasm`). +/// 2. Scanning the `plugins/` directory for `.wasm` files containing valid pumpkin custom sections. +/// 3. Scanning current working directory for `.wasm` files. +/// +/// # Errors +/// +/// Returns `WasmError::Io` if no valid WASM binary can be found or read. +pub fn find_self_wasm(data_folder: &std::path::Path) -> Result, WasmError> { + // 1. Check data folder hint + if let Some(folder_name) = data_folder.file_name().and_then(|n| n.to_str()) { + if folder_name != "data" && !folder_name.is_empty() { + let candidate_paths = [ + format!("plugins/{folder_name}.wasm"), + format!("plugins/{folder_name}.cwasm"), + format!("plugins/{folder_name}"), + format!("{folder_name}.wasm"), + ]; + for path in &candidate_paths { + if let Ok(bytes) = std::fs::read(path) { + return Ok(bytes); + } + } + } + } + + // 2. Scan `plugins/` directory + if let Ok(entries) = std::fs::read_dir("plugins") { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext == "wasm" || ext == "cwasm" { + if let Ok(bytes) = std::fs::read(&path) { + // Check if this WASM file contains pumpkin metadata + if extract_sections(&bytes).is_ok() { + return Ok(bytes); + } + } + } + } + } + } + + // 3. Scan current directory + if let Ok(entries) = std::fs::read_dir(".") { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext == "wasm" || ext == "cwasm" { + if let Ok(bytes) = std::fs::read(&path) { + if extract_sections(&bytes).is_ok() { + return Ok(bytes); + } + } + } + } + } + } + + Err(WasmError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not locate plugin WASM binary with pumpkin metadata in plugins directory", + ))) +} diff --git a/crates/pumpkin-plugin-utils/tests/licensing_test.rs b/crates/pumpkin-plugin-utils/tests/licensing_test.rs new file mode 100644 index 000000000..6644b9254 --- /dev/null +++ b/crates/pumpkin-plugin-utils/tests/licensing_test.rs @@ -0,0 +1,207 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::uninlined_format_args +)] + +use ed25519_dalek::Signer; +use pumpkin_plugin_utils::{ + CheckLicenseResponse, LicenseChecker, LicenseLease, LicenseStatus, PumpkinMetadata, + WasmSignatureEnvelope, extract_sections, get_metadata, init_with_bytes, metadata, + strip_pumpkin_sections, verify_signature, +}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tempfile::tempdir; +use wasm_encoder::{CustomSection, Encode, Module}; + +/// Helper to build a signed WASM module with `pumpkin.metadata` and `wasm_signature`. +fn create_signed_wasm( + metadata: &PumpkinMetadata, + signing_key: &ed25519_dalek::SigningKey, +) -> (Vec, String) { + let module = Module::new(); + let clean_wasm = module.finish(); + + let meta_json = serde_json::to_vec(metadata).unwrap(); + + let mut sign_payload = Vec::new(); + sign_payload.extend_from_slice(&clean_wasm); + sign_payload.extend_from_slice(&meta_json); + + let signature = signing_key.sign(&sign_payload); + let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); + + let envelope = WasmSignatureEnvelope { + version: 1, + algorithm: "Ed25519".to_string(), + public_key_hex: pub_key_hex.clone(), + signature_hex: hex::encode(signature.to_bytes()), + }; + let sig_json = serde_json::to_vec(&envelope).unwrap(); + + let mut out = clean_wasm; + let meta_section = CustomSection { + name: "pumpkin.metadata".into(), + data: meta_json.as_slice().into(), + }; + let sig_section = CustomSection { + name: "wasm_signature".into(), + data: sig_json.as_slice().into(), + }; + + meta_section.encode(&mut out); + sig_section.encode(&mut out); + + (out, pub_key_hex) +} + +#[test] +fn wasm_custom_section_extraction_and_stripping() { + let seed = [7u8; 32]; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + + let metadata = PumpkinMetadata { + marketplace_url: "http://127.0.0.1:0".to_string(), + plugin_id: 42, + plugin_name: "super-shop".to_string(), + version: "1.0.0".to_string(), + dev_id: 10, + dev_name: "alex".to_string(), + is_paid: true, + user_id: 101, + license_key: Some("LIC-9999".to_string()), + issued_at: "2026-08-17T08:00:00Z".to_string(), + }; + + let (wasm_bytes, pub_key_hex) = create_signed_wasm(&metadata, &signing_key); + + let extracted = extract_sections(&wasm_bytes).expect("Should extract custom sections"); + assert_eq!(extracted.metadata, metadata); + assert_eq!(extracted.signature_envelope.public_key_hex, pub_key_hex); + + let stripped = strip_pumpkin_sections(&wasm_bytes).expect("Should strip sections"); + assert_eq!(stripped, extracted.clean_wasm); + + let res = verify_signature( + &extracted.clean_wasm, + &extracted.metadata_raw, + &extracted.signature_envelope, + &pub_key_hex, + ); + assert!(res.is_ok()); +} + +#[test] +fn license_checker_offline_and_grace_period() { + let dir = tempdir().unwrap(); + let data_folder = dir.path(); + + let seed = [9u8; 32]; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + + let metadata = PumpkinMetadata { + marketplace_url: "http://127.0.0.1:0".to_string(), + plugin_id: 123, + plugin_name: "anti-grief".to_string(), + version: "1.0.0".to_string(), + dev_id: 5, + dev_name: "dev".to_string(), + is_paid: true, + user_id: 500, + license_key: Some("KEY-12345".to_string()), + issued_at: "2026-08-17T08:00:00Z".to_string(), + }; + + let (wasm_bytes, _pub_key_hex) = create_signed_wasm(&metadata, &signing_key); + + let checker = LicenseChecker::new(data_folder); + + // 1. Verify offline directly + let verified_meta = checker.verify_offline(&wasm_bytes).unwrap(); + assert_eq!(verified_meta.user_id, 500); + + // 2. Evaluate with expired lease -> should enter GracePeriod + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let lease = LicenseLease { + plugin_name: "anti-grief".to_string(), + license_key: Some("KEY-12345".to_string()), + status: "valid".to_string(), + last_verified_timestamp: now - 86400 * 2, // 2 days ago + expires_timestamp: now - 3600, // Expired 1 hour ago + }; + checker.write_cached_lease(&lease).unwrap(); + + let status = checker.evaluate_license(&wasm_bytes, 7); + match status { + LicenseStatus::GracePeriod { + metadata: m, + days_remaining, + .. + } => { + assert_eq!(m.plugin_id, 123); + assert!(days_remaining <= 5); + } + other => panic!("Expected GracePeriod, got {other:?}"), + } + + // 3. Evaluate with active lease -> should be Valid + let active_lease = LicenseLease { + plugin_name: "anti-grief".to_string(), + license_key: Some("KEY-12345".to_string()), + status: "valid".to_string(), + last_verified_timestamp: now, + expires_timestamp: now + 86400 * 7, + }; + checker.write_cached_lease(&active_lease).unwrap(); + + let status = checker.evaluate_license(&wasm_bytes, 7); + match status { + LicenseStatus::Valid(m) => assert_eq!(m.user_id, 500), + other => panic!("Expected Valid, got {other:?}"), + } +} + +#[test] +fn global_init_and_metadata_access() { + let dir = tempdir().unwrap(); + let data_folder = dir.path(); + + let seed = [11u8; 32]; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + + let original_meta = PumpkinMetadata { + marketplace_url: "http://127.0.0.1:0".to_string(), + plugin_id: 777, + plugin_name: "economy-core".to_string(), + version: "3.2.1".to_string(), + dev_id: 20, + dev_name: "pumpkin-dev".to_string(), + is_paid: true, + user_id: 8888, + license_key: Some("KEY-8888".to_string()), + issued_at: "2026-08-17T08:00:00Z".to_string(), + }; + + let (wasm_bytes, _pub_key_hex) = create_signed_wasm(&original_meta, &signing_key); + + // 1. Initialize globally with bytes + let verified = init_with_bytes(&wasm_bytes, data_folder).expect("Init should succeed"); + assert_eq!(verified.plugin_name, "economy-core"); + assert_eq!(verified.version, "3.2.1"); + assert_eq!(verified.user_id, 8888); + + // 2. Global metadata access + assert_eq!(get_metadata().unwrap().plugin_id, 777); + assert_eq!(metadata().unwrap().dev_name, "pumpkin-dev"); + + // 3. Verify check license models deserialize properly + let check_resp = + serde_json::from_str::(r#"{"valid":true,"status":"valid"}"#).unwrap(); + assert!(check_resp.valid); + assert_eq!(check_resp.status, "valid"); +} diff --git a/crates/pumpkin-plugin-wit b/crates/pumpkin-plugin-wit index 54f158f10..78b1b8b90 160000 --- a/crates/pumpkin-plugin-wit +++ b/crates/pumpkin-plugin-wit @@ -1 +1 @@ -Subproject commit 54f158f102bdc7f1131451664b86cfd041033afb +Subproject commit 78b1b8b9005d41d6ceb6e8788fb21840128cf29c diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/marketplace.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/marketplace.rs new file mode 100644 index 000000000..4a983de7a --- /dev/null +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/marketplace.rs @@ -0,0 +1,9 @@ +use crate::plugin::loader::wasm::wasm_host::{ + signature, state::PluginHostState, wit::v0_1::pumpkin, +}; + +impl pumpkin::plugin::marketplace::Host for PluginHostState { + async fn get_public_key(&mut self) -> wasmtime::Result> { + Ok(signature::fetch_market_public_key().ok()) + } +} diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/mod.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/mod.rs index d88abe0bb..451f9ed94 100644 --- a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/mod.rs +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/mod.rs @@ -26,6 +26,7 @@ pub mod ipc; pub mod item_stack; pub mod java_dialogs; pub mod logging; +pub mod marketplace; pub mod permission; pub mod player; pub mod recipe;