mirror of
https://github.com/Pumpkin-MC/Pumpkin.git
synced 2026-08-30 20:14:23 +00:00
fix: pumpkin-plugin-utils
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(§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::<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",
|
||||
)))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Submodule crates/pumpkin-plugin-wit updated: 78b1b8b900...8339cbf6ec
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user