use ed25519_dalek::{Signature, Signer, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use tracing::warn; use wasm_encoder::{CustomSection, Encode}; use wasmparser::{Parser, Payload}; pub const WASM_SIGNATURE_SECTION: &str = "wasm_signature"; pub const PUMPKIN_METADATA_SECTION: &str = "pumpkin.metadata"; pub const PUMPKIN_MARKET_PUBLIC_KEY_URL: &str = "https://market.pumpkinmc.org/api/v1/rest/public-key"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PumpkinMetadata { pub marketplace_url: String, pub plugin_id: i64, pub plugin_name: String, pub version: String, pub dev_id: i64, pub dev_name: String, pub is_paid: bool, pub user_id: i64, pub license_key: Option, pub issued_at: String, } /// Standard W3C Wasm-Sign signature envelope structure #[derive(Debug, Serialize, Deserialize, Clone)] pub struct WasmSignatureEnvelope { pub version: u8, pub algorithm: String, pub public_key_hex: String, pub signature_hex: String, } /// Keypair manager for Ed25519 signing and verification. pub struct KeyManager { signing_key: ed25519_dalek::SigningKey, } impl KeyManager { /// Loads or generates an Ed25519 keypair from a 32-byte secret hex. #[must_use] pub fn new(secret_hex: &str) -> Self { let mut seed = [0u8; 32]; if let Ok(decoded) = hex::decode(secret_hex) && decoded.len() == 32 { seed.copy_from_slice(&decoded); } let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); Self { signing_key } } #[must_use] pub fn public_key_hex(&self) -> String { hex::encode(self.signing_key.verifying_key().to_bytes()) } /// Sign WASM module using official W3C `wasmsign2::KeyPair` specification. #[must_use] pub fn sign(&self, data: &[u8]) -> Vec { self.signing_key.sign(data).to_bytes().to_vec() } } 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 any existing `pumpkin.metadata` and `wasm_signature` sections from a WASM binary. pub fn strip_pumpkin_sections(wasm_bytes: &[u8]) -> Result, String> { 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() == "pumpkin.signature" { } else { last_valid_end = cs.range().end; } } Ok(p) => { if let Some((_, range)) = p.as_section() { last_valid_end = range.end; } } Err(_) => { // Stopped parsing due to unparsable trailing bytes (e.g. invalid custom section appended) break; } } } Ok(wasm_bytes[..last_valid_end].to_vec()) } /// Injects `pumpkin.metadata` and official `wasm_signature` into a WASM binary using `wasmsign2` standard section structure. pub fn inject_pumpkin_sections( wasm_bytes: &[u8], meta: &PumpkinMetadata, key_manager: &KeyManager, ) -> Result, String> { let clean_wasm = strip_pumpkin_sections(wasm_bytes)?; let meta_json = serde_json::to_vec(meta).map_err(|e| e.to_string())?; let mut sign_payload = Vec::new(); sign_payload.extend_from_slice(&clean_wasm); sign_payload.extend_from_slice(&meta_json); let raw_sig = key_manager.sign(&sign_payload); let sig_envelope = WasmSignatureEnvelope { version: 1, algorithm: "Ed25519".into(), public_key_hex: key_manager.public_key_hex(), signature_hex: hex::encode(&raw_sig), }; let sig_json = serde_json::to_vec(&sig_envelope).map_err(|e| e.to_string())?; let meta_section = CustomSection { name: PUMPKIN_METADATA_SECTION.into(), data: meta_json.as_slice().into(), }; let sig_section = CustomSection { name: WASM_SIGNATURE_SECTION.into(), data: sig_json.as_slice().into(), }; let mut out = clean_wasm; meta_section.encode(&mut out); sig_section.encode(&mut out); Ok(out) } /// Verification result for inspecting a WASM binary. #[derive(Debug, Serialize, Deserialize)] pub struct VerificationResult { pub is_valid: bool, pub is_signed: bool, pub error: Option, pub metadata: Option, pub signature_envelope: Option, pub public_key_hex: String, } /// Fetches public key from market REST API. pub fn fetch_market_public_key() -> Result { let mut response = ureq::get(PUMPKIN_MARKET_PUBLIC_KEY_URL) .header("User-Agent", "Pumpkin-MC") .call() .map_err(|e| format!("Failed to fetch public key from market: {e}"))?; let body = response .body_mut() .read_to_string() .map_err(|e| format!("Failed to read public key response: {e}"))?; let key = body.trim().trim_matches('"').to_string(); if key.is_empty() { Err("Fetched public key is empty".into()) } else { Ok(key) } } /// Verifies a WASM binary's pumpkin custom sections against a public key using `wasmsign2`. #[must_use] #[allow(clippy::too_many_lines)] pub fn verify_pumpkin_wasm(wasm_bytes: &[u8], public_key_hex: &str) -> VerificationResult { let clean_wasm = match strip_pumpkin_sections(wasm_bytes) { Ok(w) => w, Err(e) => { return VerificationResult { is_valid: false, is_signed: false, error: Some(format!("Failed to parse WASM structure: {e}")), metadata: None, signature_envelope: None, public_key_hex: public_key_hex.to_string(), }; } }; let mut metadata_raw: Option> = None; let mut signature_raw: Option> = None; let parser = Parser::new(0); for payload in parser.parse_all(wasm_bytes) { if let Ok(Payload::CustomSection(cs)) = payload { if cs.name() == PUMPKIN_METADATA_SECTION { metadata_raw = Some(cs.data().to_vec()); } else if cs.name() == WASM_SIGNATURE_SECTION || cs.name() == "pumpkin.signature" { signature_raw = Some(cs.data().to_vec()); } } } if metadata_raw.is_none() || signature_raw.is_none() { let clean_wasm = strip_pumpkin_sections(wasm_bytes).unwrap_or_default(); 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 && 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 == "pumpkin.signature" { signature_raw = Some(payload_data.to_vec()); } } } cursor = section_end; continue; } break; } } } let (Some(meta_bytes), Some(sig_bytes)) = (metadata_raw, signature_raw) else { return VerificationResult { is_valid: false, is_signed: false, error: Some( "WASM does not contain Pumpkin Market metadata or standard wasm_signature custom sections." .into(), ), metadata: None, signature_envelope: None, public_key_hex: public_key_hex.to_string(), }; }; let meta: PumpkinMetadata = match serde_json::from_slice(&meta_bytes) { Ok(m) => m, Err(e) => { return VerificationResult { is_valid: false, is_signed: true, error: Some(format!("Failed to parse pumpkin metadata: {e}")), metadata: None, signature_envelope: None, public_key_hex: public_key_hex.to_string(), }; } }; // Extract signature bytes and optional envelope let (raw_sig_bytes, sig_envelope) = if let Ok(envelope) = serde_json::from_slice::(&sig_bytes) { let Ok(sig_b) = hex::decode(&envelope.signature_hex) else { return VerificationResult { is_valid: false, is_signed: true, error: Some("Invalid signature hex inside signature envelope.".into()), metadata: Some(meta), signature_envelope: None, public_key_hex: public_key_hex.to_string(), }; }; (sig_b, Some(envelope)) } else { (sig_bytes, None) }; let mut sign_payload = Vec::new(); sign_payload.extend_from_slice(&clean_wasm); sign_payload.extend_from_slice(&meta_bytes); let target_pub_key = sig_envelope.as_ref().map_or_else( || public_key_hex.to_string(), |env| env.public_key_hex.clone(), ); let Ok(pub_key_bytes) = hex::decode(&target_pub_key) else { return VerificationResult { is_valid: false, is_signed: true, error: Some("Invalid public key hex format.".into()), metadata: Some(meta), signature_envelope: sig_envelope, public_key_hex: target_pub_key, }; }; let verifying_key = match VerifyingKey::try_from(pub_key_bytes.as_slice()) { Ok(vk) => vk, Err(e) => { return VerificationResult { is_valid: false, is_signed: true, error: Some(format!("Invalid Ed25519 public key: {e}")), metadata: Some(meta), signature_envelope: sig_envelope, public_key_hex: target_pub_key, }; } }; let signature = match Signature::from_slice(&raw_sig_bytes) { Ok(sig) => sig, Err(e) => { return VerificationResult { is_valid: false, is_signed: true, error: Some(format!("Invalid signature bytes: {e}")), metadata: Some(meta), signature_envelope: sig_envelope, public_key_hex: target_pub_key, }; } }; if let Err(e) = verifying_key.verify(&sign_payload, &signature) { VerificationResult { is_valid: false, is_signed: true, error: Some(format!("Signature verification failed: {e}")), metadata: Some(meta), signature_envelope: sig_envelope, public_key_hex: target_pub_key, } } else { VerificationResult { is_valid: true, is_signed: true, error: None, metadata: Some(meta), signature_envelope: sig_envelope, public_key_hex: target_pub_key, } } } /// Verifies a WASM plugin binary and logs appropriate warnings if unsigned or invalid. pub fn verify_wasm_plugin(wasm_bytes: &[u8], path_str: &str) { let public_key = fetch_market_public_key().unwrap_or_default(); let result = verify_pumpkin_wasm(wasm_bytes, &public_key); if !result.is_signed { warn!( "Plugin '{}' is unsigned. Unsigned plugins may come from untrusted sources.", path_str ); } else if !result.is_valid { warn!( "Plugin '{}' signature verification failed: {}", path_str, result.error.as_deref().unwrap_or("Unknown error") ); } }