feat: add pumpkin-plugin-utils
This commit is contained in:
parent
14337d5285
commit
b60f15a95e
18
Cargo.lock
generated
18
Cargo.lock
generated
@ -3549,6 +3549,24 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "pumpkin-protocol"
|
name = "pumpkin-protocol"
|
||||||
version = "0.1.0-dev+26.2-26.40"
|
version = "0.1.0-dev+26.2-26.40"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/pumpkin-macros",
|
"crates/pumpkin-macros",
|
||||||
"crates/pumpkin-nbt",
|
"crates/pumpkin-nbt",
|
||||||
"crates/pumpkin-plugin-api",
|
"crates/pumpkin-plugin-api",
|
||||||
|
"crates/pumpkin-plugin-utils",
|
||||||
"crates/pumpkin-protocol",
|
"crates/pumpkin-protocol",
|
||||||
"crates/pumpkin-util",
|
"crates/pumpkin-util",
|
||||||
"crates/pumpkin-world",
|
"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-inventory = { path = "crates/pumpkin-inventory", default-features = false }
|
||||||
pumpkin-macros = { path = "crates/pumpkin-macros", default-features = false }
|
pumpkin-macros = { path = "crates/pumpkin-macros", default-features = false }
|
||||||
pumpkin-nbt = { path = "crates/pumpkin-nbt", 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-protocol = { path = "crates/pumpkin-protocol", default-features = false }
|
||||||
pumpkin-util = { path = "crates/pumpkin-util", default-features = false }
|
pumpkin-util = { path = "crates/pumpkin-util", default-features = false }
|
||||||
pumpkin-world = { path = "crates/pumpkin-world", 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"] }
|
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
|
||||||
tracing-serde-structured = { version = "0.4", default-features = false }
|
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 }
|
||||||
|
|||||||
@ -111,9 +111,9 @@ pub use wit::pumpkin::plugin::{
|
|||||||
enchantments as enchantments_wit, entity,
|
enchantments as enchantments_wit, entity,
|
||||||
entity_types::EntityType,
|
entity_types::EntityType,
|
||||||
event::{self as events_wit, EventType},
|
event::{self as events_wit, EventType},
|
||||||
gui, i18n, ipc, item_stack, java_dialogs, java_packets, particles, permission, player,
|
gui, i18n, ipc, item_stack, java_dialogs, java_packets, marketplace, particles, permission,
|
||||||
recipe as recipe_wit, scoreboard, screens as screens_wit, server, statistics as statistics_wit,
|
player, recipe as recipe_wit, scoreboard, screens as screens_wit, server,
|
||||||
text, uuid, world,
|
statistics as statistics_wit, text, uuid, world,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convenience re-exports of commonly-used plugin types so plugin authors can
|
// Convenience re-exports of commonly-used plugin types so plugin authors can
|
||||||
|
|||||||
25
crates/pumpkin-plugin-utils/Cargo.toml
Normal file
25
crates/pumpkin-plugin-utils/Cargo.toml
Normal 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
|
||||||
149
crates/pumpkin-plugin-utils/src/crypto.rs
Normal file
149
crates/pumpkin-plugin-utils/src/crypto.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
93
crates/pumpkin-plugin-utils/src/http.rs
Normal file
93
crates/pumpkin-plugin-utils/src/http.rs
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/pumpkin-plugin-utils/src/lib.rs
Normal file
212
crates/pumpkin-plugin-utils/src/lib.rs
Normal 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}¤t_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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
289
crates/pumpkin-plugin-utils/src/license.rs
Normal file
289
crates/pumpkin-plugin-utils/src/license.rs
Normal 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
|
||||||
|
}
|
||||||
113
crates/pumpkin-plugin-utils/src/models.rs
Normal file
113
crates/pumpkin-plugin-utils/src/models.rs
Normal 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,
|
||||||
|
}
|
||||||
81
crates/pumpkin-plugin-utils/src/resolver.rs
Normal file
81
crates/pumpkin-plugin-utils/src/resolver.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
87
crates/pumpkin-plugin-utils/src/updater.rs
Normal file
87
crates/pumpkin-plugin-utils/src/updater.rs
Normal 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}¤t_version={version}`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `UpdateError` if the network request or JSON parsing fails.
|
||||||
|
pub fn check_for_updates(
|
||||||
|
&self,
|
||||||
|
plugin_name: &str,
|
||||||
|
current_version_str: &str,
|
||||||
|
marketplace_url: &str,
|
||||||
|
) -> Result<CheckUpdateResponse, UpdateError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/rest/check-update?plugin_name={}¤t_version={}",
|
||||||
|
marketplace_url.trim_end_matches('/'),
|
||||||
|
urlencoding(plugin_name),
|
||||||
|
urlencoding(current_version_str),
|
||||||
|
);
|
||||||
|
debug!("Checking for updates at {url}");
|
||||||
|
|
||||||
|
let response_str = self.http_client.get(&url)?;
|
||||||
|
let update_response: CheckUpdateResponse = serde_json::from_str(&response_str)?;
|
||||||
|
|
||||||
|
Ok(update_response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal URL encoding helper for query parameters.
|
||||||
|
fn urlencoding(input: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(input.len());
|
||||||
|
for byte in input.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
encoded.push_str(&format!("%{:02X}", byte));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
254
crates/pumpkin-plugin-utils/src/wasm.rs
Normal file
254
crates/pumpkin-plugin-utils/src/wasm.rs
Normal 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(§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",
|
||||||
|
)))
|
||||||
|
}
|
||||||
207
crates/pumpkin-plugin-utils/tests/licensing_test.rs
Normal file
207
crates/pumpkin-plugin-utils/tests/licensing_test.rs
Normal 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");
|
||||||
|
}
|
||||||
@ -1 +1 @@
|
|||||||
Subproject commit 54f158f102bdc7f1131451664b86cfd041033afb
|
Subproject commit 78b1b8b9005d41d6ceb6e8788fb21840128cf29c
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ pub mod ipc;
|
|||||||
pub mod item_stack;
|
pub mod item_stack;
|
||||||
pub mod java_dialogs;
|
pub mod java_dialogs;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod marketplace;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user