feat: add pumpkin-plugin-utils

This commit is contained in:
Alexander Medvedev
2026-08-17 10:34:07 +02:00
parent 14337d5285
commit b60f15a95e
16 changed files with 1547 additions and 4 deletions

18
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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<String, HttpError> {
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<String, HttpError> {
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()))
}
}

View File

@@ -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<PumpkinMetadata> = OnceLock::new();
/// Global cache for plugin data folder path.
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.
///
/// # 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<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.
#[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<CheckLicenseResponse, LicenseError> {
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}&current_version={version}`
///
/// # Errors
///
/// Returns `UpdateError` if querying the marketplace fails or if `init` was not called.
pub fn check_for_updates() -> Result<CheckUpdateResponse, UpdateError> {
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()),
}
}

View File

@@ -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<Path>) -> 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<LicenseLease> {
let path = self.lease_path();
if path.exists() {
if let Ok(data) = std::fs::read(path) {
if let Ok(lease) = serde_json::from_slice::<LicenseLease>(&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<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(),
));
}
// 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);
}
// 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<CheckLicenseResponse, LicenseError> {
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
}

View File

@@ -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<String>,
/// 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<String>,
/// 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<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

@@ -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 (`<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

@@ -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}&current_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<CheckUpdateResponse, UpdateError> {
let url = format!(
"{}/api/v1/rest/check-update?plugin_name={}&current_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
}

View File

@@ -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<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

@@ -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<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,
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::<CheckLicenseResponse>(r#"{"valid":true,"status":"valid"}"#).unwrap();
assert!(check_resp.valid);
assert_eq!(check_resp.status, "valid");
}

View File

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

View File

@@ -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;