fix: pumpkin-plugin-utils

This commit is contained in:
Alexander Medvedev
2026-08-18 15:46:30 +02:00
parent 47ea95736a
commit 5b833274d4
16 changed files with 128 additions and 777 deletions

4
Cargo.lock generated
View File

@@ -3553,8 +3553,6 @@ dependencies = [
name = "pumpkin-plugin-utils"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"hex",
"pumpkin-plugin-api",
"serde",
"serde_json",
@@ -3562,8 +3560,6 @@ dependencies = [
"thiserror 2.0.20",
"tracing",
"ureq",
"wasm-encoder 0.256.0",
"wasmparser 0.256.0",
]
[[package]]

View File

@@ -106,14 +106,14 @@ pub mod command {
pub use wit::pumpkin::plugin::{
advancement as advancement_wit, bedrock_packets, block_entity, boss_bar,
command as command_wit, common,
context::{Context, Server},
context::{self, Context, MarketplaceMetadata, Server},
damage_types as damage_types_wit, data_components, display as display_wit,
enchantments as enchantments_wit, entity,
entity_types::EntityType,
event::{self as events_wit, EventType},
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,
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,
};
// Convenience re-exports of commonly-used plugin types so plugin authors can

View File

@@ -3,21 +3,17 @@ 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."
description = "Utilities for Pumpkin plugins: licensing, lease caching, 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 }
wasmparser = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
ureq = { workspace = true, features = ["json"] }
[dev-dependencies]
wasm-encoder = "0.256"
tempfile = { workspace = true }
[lints]

View File

@@ -1,149 +0,0 @@
//! 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());
}
}

View File

@@ -1,12 +1,10 @@
//! # 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.
//! - **Automatic Metadata Caching**: Call `init(context)` once on load; marketplace metadata is retrieved from host WIT and cached globally.
//! - **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
//!
@@ -20,7 +18,7 @@
//! fn new() -> Self { MyPlugin }
//!
//! fn on_load(&self, context: &Context) -> Result<(), String> {
//! // 1. Initialize plugin-utils (verifies signature & caches metadata globally)
//! // 1. Initialize plugin-utils (retrieves verified marketplace metadata from host)
//! let metadata = pumpkin_plugin_utils::init(context)
//! .map_err(|e| format!("Plugin initialization failed: {e}"))?;
//!
@@ -59,32 +57,21 @@
)]
#![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.
/// Data models for metadata, 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,
LicenseStatus, PumpkinMetadata,
};
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},
@@ -98,57 +85,50 @@ static GLOBAL_DATA_FOLDER: OnceLock<PathBuf> = 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.
/// Retrieves verified marketplace metadata provided by the host if the plugin is signed,
/// and caches it globally.
///
/// # Errors
///
/// Returns `LicenseError` if signature verification, section extraction, or public key resolution fails.
/// Returns `LicenseError::UnsignedPlugin` if the plugin is not signed or marketplace metadata is missing.
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)
#[cfg(target_arch = "wasm32")]
{
if let Some(market_meta) = context.get_marketplace_metadata() {
let meta: PumpkinMetadata = market_meta.into();
return init_with_metadata(meta, data_folder);
}
Err(LicenseError::UnsignedPlugin)
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = data_folder;
GLOBAL_METADATA.get().ok_or(LicenseError::NotInitialized)
}
}
/// Initializes `pumpkin-plugin-utils` with a specific data folder path.
/// Initializes `pumpkin-plugin-utils` with explicit metadata (useful for tests or custom initialization).
///
/// # Errors
///
/// Returns `LicenseError` if signature verification or section extraction fails.
pub fn init_with_folder(
/// Returns `LicenseError::NotInitialized` if caching fails.
pub fn init_with_metadata(
metadata: PumpkinMetadata,
data_folder: impl AsRef<Path>,
) -> 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<Path>,
) -> 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.
/// Returns a reference to the globally cached metadata if `init` has been called.
#[must_use]
pub fn get_metadata() -> Option<&'static PumpkinMetadata> {
GLOBAL_METADATA.get()
@@ -197,16 +177,16 @@ pub fn check_for_updates() -> Result<CheckUpdateResponse, UpdateError> {
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)
/// Evaluates the complete offline license status (metadata + 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 Some(meta) = get_metadata() 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()),
}
checker.evaluate_license(meta, grace_period_days)
}

View File

@@ -1,11 +1,8 @@
//! License validation, offline verification, and online leasing.
//! License validation, leasing, and offline grace periods.
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},
@@ -17,15 +14,6 @@ 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),
@@ -44,6 +32,9 @@ pub enum LicenseError {
/// JSON serialization error.
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
/// Plugin is unsigned or missing marketplace metadata.
#[error("Plugin is unsigned or missing marketplace metadata")]
UnsignedPlugin,
/// Plugin has not been initialized.
#[error(
"Plugin-utils has not been initialized (call pumpkin_plugin_utils::init(context) first)"
@@ -51,10 +42,9 @@ pub enum LicenseError {
NotInitialized,
}
/// Manages fast offline signature checks and background online license validation.
/// Manages license checks, cached leases, and offline grace periods.
pub struct LicenseChecker {
data_folder: PathBuf,
resolver: PublicKeyResolver,
http_client: HttpClient,
}
@@ -65,7 +55,6 @@ impl LicenseChecker {
let folder = data_folder.as_ref().to_path_buf();
Self {
data_folder: folder,
resolver: PublicKeyResolver::new(),
http_client: HttpClient::default(),
}
}
@@ -109,72 +98,22 @@ impl LicenseChecker {
.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<PumpkinMetadata, LicenseError> {
// 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(),
));
/// Evaluates the complete license status (metadata + lease cache + grace period).
#[must_use]
pub fn evaluate_license(
&self,
metadata: &PumpkinMetadata,
grace_period_days: u32,
) -> LicenseStatus {
// Free/Open-Source plugins are always valid
if !metadata.is_paid {
return LicenseStatus::Valid(metadata.clone());
}
// 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<PumpkinMetadata, LicenseError> {
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);
if metadata.license_key.is_none() {
return LicenseStatus::Invalid(
"Paid plugin metadata is missing a license_key".to_string(),
);
}
// For paid plugins, inspect cached lease
@@ -186,7 +125,7 @@ impl LicenseChecker {
&& lease.status == "valid"
&& now <= lease.expires_timestamp
{
return LicenseStatus::Valid(metadata);
return LicenseStatus::Valid(metadata.clone());
}
// Check if within grace period
@@ -196,7 +135,7 @@ impl LicenseChecker {
(lease.last_verified_timestamp + grace_seconds).saturating_sub(now);
let days_remaining = (seconds_left / 86400).max(1) as u32;
return LicenseStatus::GracePeriod {
metadata,
metadata: metadata.clone(),
days_remaining,
reason: "Operating in offline grace period with previous valid lease"
.to_string(),
@@ -205,7 +144,7 @@ impl LicenseChecker {
}
// If no cached lease exists yet (first run) and offline, allow initial valid state
LicenseStatus::Valid(metadata)
LicenseStatus::Valid(metadata.clone())
}
/// Checks the license online against the marketplace REST API:

View File

@@ -2,13 +2,6 @@
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";
@@ -37,27 +30,31 @@ pub struct PumpkinMetadata {
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,
impl From<pumpkin_plugin_api::MarketplaceMetadata> for PumpkinMetadata {
fn from(m: pumpkin_plugin_api::MarketplaceMetadata) -> Self {
Self {
marketplace_url: m.marketplace_url,
plugin_id: m.plugin_id,
plugin_name: m.plugin_name,
version: m.version,
dev_id: m.dev_id,
dev_name: m.dev_name,
is_paid: m.is_paid,
user_id: m.user_id,
license_key: m.license_key,
issued_at: m.issued_at,
}
}
}
/// Result of evaluating a plugin's license.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LicenseStatus {
/// The license is completely valid and verified.
/// The license is completely valid.
Valid(PumpkinMetadata),
/// Operating in an offline grace period with valid cached lease.
GracePeriod {
/// The verified metadata.
/// The metadata.
metadata: PumpkinMetadata,
/// Remaining days in the grace period.
days_remaining: u32,
@@ -102,12 +99,3 @@ pub struct CheckUpdateResponse {
/// The latest stable version string, if one exists.
pub latest_version: Option<String>,
}
/// 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,
}

View File

@@ -1,81 +0,0 @@
//! 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 (`<marketplace_url>/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<String, ResolveError> {
// 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::<crate::models::MarketplacePublicKeyResponse>(&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())
}
}

View File

@@ -1,254 +0,0 @@
//! 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<Vec<u8>, 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<u8>,
/// Parsed signature envelope.
pub signature_envelope: WasmSignatureEnvelope,
/// Clean WASM bytes without metadata and signature custom sections.
pub clean_wasm: Vec<u8>,
}
/// 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<ExtractedSections, WasmError> {
let clean_wasm = strip_pumpkin_sections(wasm_bytes)?;
let mut metadata_raw: Option<Vec<u8>> = None;
let mut signature_raw: Option<Vec<u8>> = 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(&section_data[name_start..name_end])
{
let payload_data = &section_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::<WasmSignatureEnvelope>(&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/<name>` -> `plugins/<name>.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<Vec<u8>, 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",
)))
}

View File

@@ -5,101 +5,18 @@
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,
CheckLicenseResponse, CheckUpdateResponse, LicenseChecker, LicenseLease, LicenseStatus,
PumpkinMetadata, get_metadata, init_with_metadata, metadata,
};
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<u8>, 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,
@@ -113,15 +30,9 @@ fn license_checker_offline_and_grace_period() {
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
// 1. Evaluate with expired lease -> should enter GracePeriod
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -136,7 +47,7 @@ fn license_checker_offline_and_grace_period() {
};
checker.write_cached_lease(&lease).unwrap();
let status = checker.evaluate_license(&wasm_bytes, 7);
let status = checker.evaluate_license(&metadata, 7);
match status {
LicenseStatus::GracePeriod {
metadata: m,
@@ -149,7 +60,7 @@ fn license_checker_offline_and_grace_period() {
other => panic!("Expected GracePeriod, got {other:?}"),
}
// 3. Evaluate with active lease -> should be Valid
// 2. Evaluate with active lease -> should be Valid
let active_lease = LicenseLease {
plugin_name: "anti-grief".to_string(),
license_key: Some("KEY-12345".to_string()),
@@ -159,7 +70,7 @@ fn license_checker_offline_and_grace_period() {
};
checker.write_cached_lease(&active_lease).unwrap();
let status = checker.evaluate_license(&wasm_bytes, 7);
let status = checker.evaluate_license(&metadata, 7);
match status {
LicenseStatus::Valid(m) => assert_eq!(m.user_id, 500),
other => panic!("Expected Valid, got {other:?}"),
@@ -171,9 +82,6 @@ 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,
@@ -187,10 +95,8 @@ fn global_init_and_metadata_access() {
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");
// 1. Initialize globally with metadata
let verified = init_with_metadata(original_meta, 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);
@@ -204,4 +110,11 @@ fn global_init_and_metadata_access() {
serde_json::from_str::<CheckLicenseResponse>(r#"{"valid":true,"status":"valid"}"#).unwrap();
assert!(check_resp.valid);
assert_eq!(check_resp.status, "valid");
let update_resp = serde_json::from_str::<CheckUpdateResponse>(
r#"{"update_available":true,"latest_version":"4.0.0"}"#,
)
.unwrap();
assert!(update_resp.update_available);
assert_eq!(update_resp.latest_version.as_deref(), Some("4.0.0"));
}

View File

@@ -92,7 +92,26 @@ impl PluginRuntime {
) -> Result<(Arc<WasmPlugin>, PluginMetadata), PluginInitError> {
let wasm_bytes = std::fs::read(&path).map_err(PluginInitError::FileReadFailed)?;
signature::verify_wasm_plugin(&wasm_bytes, &path.as_ref().to_string_lossy());
let verification =
signature::verify_wasm_plugin(&wasm_bytes, &path.as_ref().to_string_lossy());
let marketplace_metadata = if verification.is_signed && verification.is_valid {
verification.metadata.map(|m| {
wit::v0_1::pumpkin::plugin::context::MarketplaceMetadata {
marketplace_url: m.marketplace_url,
plugin_id: m.plugin_id,
plugin_name: m.plugin_name,
version: m.version,
dev_id: m.dev_id,
dev_name: m.dev_name,
is_paid: m.is_paid,
user_id: m.user_id,
license_key: m.license_key,
issued_at: m.issued_at,
}
})
} else {
None
};
let wasm_bytes = signature::strip_pumpkin_sections(&wasm_bytes).unwrap_or(wasm_bytes);
@@ -111,7 +130,11 @@ impl PluginRuntime {
};
let wasm_plugin = Arc::new(wasm_plugin);
wasm_plugin.store.lock().await.data_mut().plugin = Some(Arc::downgrade(&wasm_plugin));
{
let mut store = wasm_plugin.store.lock().await;
store.data_mut().plugin = Some(Arc::downgrade(&wasm_plugin));
store.data_mut().marketplace_metadata = marketplace_metadata;
};
Ok((wasm_plugin, metadata))
}
}

View File

@@ -114,6 +114,8 @@ pub struct PluginHostState {
pub server: Option<Arc<Server>>,
pub permissions: Vec<String>,
pub name: Option<String>,
pub marketplace_metadata:
Option<crate::plugin::loader::wasm::wasm_host::wit::v0_1::pumpkin::plugin::context::MarketplaceMetadata>,
}
impl Default for PluginHostState {
@@ -139,6 +141,7 @@ impl PluginHostState {
server: None,
permissions: Vec::new(),
name: None,
marketplace_metadata: None,
}
}

View File

@@ -10,7 +10,7 @@ use crate::plugin::loader::wasm::wasm_host::{
self,
plugin::{
command::Command,
context::Context,
context::{Context, MarketplaceMetadata},
event::{EventPriority, EventType},
permission::{Permission, PermissionDefault, PermissionLevel},
server::Server,
@@ -1982,6 +1982,13 @@ impl pumpkin::plugin::context::HostContext for PluginHostState {
.map_err(|_| wasmtime::Error::msg("failed to add server resource"))
}
async fn get_marketplace_metadata(
&mut self,
_context: Resource<Context>,
) -> wasmtime::Result<Option<MarketplaceMetadata>> {
Ok(self.marketplace_metadata.clone())
}
async fn drop(&mut self, rep: Resource<Context>) -> wasmtime::Result<()> {
let _ = self
.resource_table

View File

@@ -1,9 +0,0 @@
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<Option<String>> {
Ok(signature::fetch_market_public_key().ok())
}
}

View File

@@ -26,7 +26,6 @@ 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;