diff --git a/Cargo.lock b/Cargo.lock index 5a78a51..e2389c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2283,7 +2283,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vortex-mod-mega" -version = "1.0.0" +version = "1.1.0" dependencies = [ "aes", "base64", diff --git a/Cargo.toml b/Cargo.toml index a28475e..e4a5a2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vortex-mod-mega" -version = "1.0.0" +version = "1.1.0" edition = "2021" description = "MEGA WASM plugin for Vortex — resolves mega.nz files/folders and exposes AES-128-CTR + chunk MAC for host-side decryption" license = "GPL-3.0" diff --git a/README.md b/README.md index 58e24c9..3f90529 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # vortex-mod-mega -MEGA WASM plugin for [Vortex](https://github.com/vortex-app/vortex) — resolves `mega.nz` public file URLs and exposes the AES-128-CTR + chunk-MAC machinery the Vortex core engine needs to decrypt downloaded bytes and verify integrity. +MEGA WASM plugin for [Vortex](https://github.com/vortex-app/vortex) — recognises `mega.nz` file and folder URLs. Downloads are refused until the Vortex host can decrypt MEGA payloads: MEGA CDN bytes are AES-128-CTR ciphertext, and without a host-side decryption pipeline a "successful" download would write unreadable bytes to disk (MAT-136 R-04). ## Status -- File URL resolution (`https://mega.nz/file/#`) — done -- AES-128-CTR streaming decryption (memory-bounded, native-tested) — done -- Per-chunk CBC-MAC accumulator + final file-MAC fold — done -- MAC mismatch detection (`PluginError::MacMismatch`) — done -- Folder URL enumeration (`https://mega.nz/folder/#`) — done -- AES-128-ECB unwrap of per-node keys + AES-128-CBC decrypt of attribute blobs — done +- URL recognition (`can_handle` / `supports_playlist`) — active +- Downloads (`extract_links` / `resolve_stream_url`) — refused with `DecryptionNotSupported` until host-side decryption ships +- Library machinery, tested and ready to rewire (see git history for the previous export wiring): + - File URL resolution (`https://mega.nz/file/#`) + - AES-128-CTR streaming decryption (memory-bounded, native-tested) + - Per-chunk CBC-MAC accumulator + final file-MAC fold, MAC mismatch detection (`PluginError::MacMismatch`) + - Folder URL enumeration, AES-128-ECB unwrap of per-node keys + AES-128-CBC decrypt of attribute blobs ## Plugin contract @@ -17,10 +18,10 @@ MEGA WASM plugin for [Vortex](https://github.com/vortex-app/vortex) — resolves |----------------------|-----------------------------------------------------------------------| | `can_handle` | `"true"` for any `mega.nz/file/...` or `mega.nz/folder/...` URL. | | `supports_playlist` | `"true"` for folder URLs, `"false"` otherwise. | -| `extract_links` | File URL → JSON `{ kind: "file", files: [FileLink] }` with `EncryptionInfo`. Folder URL → `{ kind: "folder", files: [FileLink, ...] }` where each child carries a synthetic `https://mega.nz/file/#` URL and `direct_url = null` (host calls `resolve_stream_url` per child). | -| `resolve_stream_url` | File URL → encrypted CDN URL (host fetches + decrypts in-stream). | +| `extract_links` | Validates the URL, then refuses with an explicit "MEGA downloads are not supported yet" error. | +| `resolve_stream_url` | Same refusal after URL validation. | -`FileLink.encryption.scheme = "mega-aes128-ctr"` carries the per-file AES key, IV and `metaMac` (all hex-encoded). Hosts must run the bytes from `direct_url` through `MegaDecryptor::process` and verify with `MegaDecryptor::verify_against(meta_mac)`. +The `MegaDecryptor` (AES-128-CTR + chunk-MAC) stays in the library for the release that wires host-side decryption; no export hands out an encrypted CDN URL in the meantime. ## Build diff --git a/plugin.toml b/plugin.toml index b269136..67ec249 100644 --- a/plugin.toml +++ b/plugin.toml @@ -1,9 +1,9 @@ [plugin] name = "vortex-mod-mega" -version = "1.0.0" +version = "1.1.0" category = "hoster" author = "vortex-community" -description = "MEGA hoster — resolves mega.nz public file URLs and exposes AES-128-CTR streaming decryption with per-chunk MAC verification" +description = "MEGA hoster — link/folder parsing and file metadata; downloads not available yet (MEGA files need client-side AES decryption, planned)" license = "GPL-3.0" min_vortex_version = "0.1.0" diff --git a/src/error.rs b/src/error.rs index 9ae2a2c..ea416d1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -34,6 +34,9 @@ pub enum PluginError { #[error("MAC mismatch: file integrity check failed")] MacMismatch, + + #[error("MEGA downloads are not supported yet: files are end-to-end encrypted (AES-128-CTR) and Vortex cannot decrypt them during download")] + DecryptionNotSupported, } #[cfg(test)] @@ -59,6 +62,13 @@ mod tests { assert!(PluginError::ApiError(-9).to_string().contains("-9")); } + #[test] + fn decryption_not_supported_names_the_reason() { + let msg = PluginError::DecryptionNotSupported.to_string(); + assert!(msg.contains("not supported yet"), "{msg}"); + assert!(msg.contains("encrypted"), "{msg}"); + } + #[test] fn mac_mismatch_message_stable() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index 5ae9693..ac51778 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ //! Implements the plugin contract used by the Vortex plugin host: //! - `can_handle(url)` → `"true"` / `"false"` //! - `supports_playlist(url)` → `"true"` for folder URLs, `"false"` otherwise -//! - `extract_links(url)` → JSON metadata for a single MEGA file -//! - `resolve_stream_url(input)` → encrypted CDN URL (host fetches + decrypts) +//! - `extract_links(url)` / `resolve_stream_url(input)` → refused with an +//! explicit error until the host can decrypt MEGA payloads (MAT-136 R-04) //! //! Network access is delegated to the host via `http_request`. All parsing //! and crypto is pure (`url_matcher.rs`, `key_parser.rs`, `crypto.rs`, diff --git a/src/plugin_api.rs b/src/plugin_api.rs index 424ae59..33d06c7 100644 --- a/src/plugin_api.rs +++ b/src/plugin_api.rs @@ -1,23 +1,9 @@ -//! WASM-only entry points: `#[plugin_fn]` exports + `#[host_fn]` imports. +//! WASM-only entry points: `#[plugin_fn]` exports. use extism_pdk::*; -use crate::api_client::{ - build_folder_request, build_g_request, parse_g_response, parse_http_response, HttpResponse, -}; use crate::error::PluginError; -use crate::key_parser::{parse_file_key, parse_folder_key}; -use crate::node_parser::parse_folder_listing; -use crate::url_matcher::UrlKind; -use crate::{ - build_file_extract_links, build_folder_extract_links, ensure_supported_url, handle_can_handle, - handle_supports_playlist, -}; - -#[host_fn] -extern "ExtismHost" { - fn http_request(req: String) -> String; -} +use crate::{ensure_supported_url, handle_can_handle, handle_supports_playlist}; #[plugin_fn] pub fn can_handle(url: String) -> FnResult { @@ -29,29 +15,17 @@ pub fn supports_playlist(url: String) -> FnResult { Ok(handle_supports_playlist(&url)) } +// MAT-136 R-04: MEGA CDN bytes are AES-128-CTR ciphertext and the Vortex host +// has no decryption pipeline yet, so a "successful" download would write +// unreadable bytes to disk. Both download-facing exports refuse with an +// explicit error until host-side decryption ships; the resolution and crypto +// code stays in the library (api_client, crypto, key_parser, node_parser) +// ready to rewire — see git history for the previous wiring. + #[plugin_fn] pub fn extract_links(url: String) -> FnResult { - let kind = ensure_supported_url(&url).map_err(error_to_fn_error)?; - let response_json = match kind { - UrlKind::File { id, key_b64 } => { - let key = parse_file_key(&key_b64).map_err(error_to_fn_error)?; - let resolution = call_g_command(&id)?; - let response = build_file_extract_links(&url, &id, &key, resolution, None); - serde_json::to_string(&response) - .map_err(|e| error_to_fn_error(PluginError::SerdeJson(e)))? - } - UrlKind::Folder { id, key_b64 } => { - let folder_key = parse_folder_key(&key_b64).map_err(error_to_fn_error)?; - let body = call_f_command(&id)?; - let children = - parse_folder_listing(&body, &folder_key.aes_key).map_err(error_to_fn_error)?; - let response = build_folder_extract_links(children); - serde_json::to_string(&response) - .map_err(|e| error_to_fn_error(PluginError::SerdeJson(e)))? - } - UrlKind::Unknown => unreachable!("ensure_supported_url filtered Unknown"), - }; - Ok(response_json) + ensure_supported_url(&url).map_err(error_to_fn_error)?; + Err(error_to_fn_error(PluginError::DecryptionNotSupported)) } #[plugin_fn] @@ -62,32 +36,8 @@ pub fn resolve_stream_url(input: String) -> FnResult { } let params: Input = serde_json::from_str(&input).map_err(|e| error_to_fn_error(PluginError::SerdeJson(e)))?; - let kind = ensure_supported_url(¶ms.url).map_err(error_to_fn_error)?; - match kind { - UrlKind::File { id, .. } => { - let resolution = call_g_command(&id)?; - Ok(resolution.direct_url) - } - _ => Err(error_to_fn_error(PluginError::UnsupportedUrl(params.url))), - } -} - -fn call_g_command(file_id: &str) -> FnResult { - let req = build_g_request(file_id).map_err(error_to_fn_error)?; - // SAFETY: see invariants in vortex-mod-mediafire's plugin_api.rs. - let raw = unsafe { http_request(req)? }; - let resp: HttpResponse = parse_http_response(&raw).map_err(error_to_fn_error)?; - let body = resp.into_success_body().map_err(error_to_fn_error)?; - parse_g_response(&body).map_err(error_to_fn_error) -} - -fn call_f_command(folder_id: &str) -> FnResult { - let req = build_folder_request(folder_id).map_err(error_to_fn_error)?; - // SAFETY: same invariants as `call_g_command` — host-resolved import, - // String-marshalled JSON envelope, http capability checked at load. - let raw = unsafe { http_request(req)? }; - let resp: HttpResponse = parse_http_response(&raw).map_err(error_to_fn_error)?; - resp.into_success_body().map_err(error_to_fn_error) + ensure_supported_url(¶ms.url).map_err(error_to_fn_error)?; + Err(error_to_fn_error(PluginError::DecryptionNotSupported)) } fn error_to_fn_error(err: PluginError) -> WithReturnCode { diff --git a/tests/folder_e2e.rs b/tests/folder_e2e.rs index 628103a..13b47e7 100644 --- a/tests/folder_e2e.rs +++ b/tests/folder_e2e.rs @@ -1,18 +1,13 @@ -//! End-to-end folder enumeration test: load the WASM artefact via Extism, -//! stub `http_request` to return a synthetic `f` command response built -//! with real AES-128-ECB key wrapping + AES-128-CBC attribute encryption, -//! then assert `extract_links` decrypts it back into a single child file -//! with the expected handle, filename and size. +//! WASM-boundary check: folder URLs are refused like file URLs until the +//! host can decrypt MEGA payloads (MAT-136 R-04). The synthetic folder +//! fixture that exercised in-wasm decryption lives in git history, ready to +//! restore when the download exports are rewired. //! //! Requires the WASM artifact at //! `target/wasm32-wasip1/release/vortex_mod_mega.wasm`. use std::path::PathBuf; -use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; -use aes::Aes128; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; use extism::{Function, UserData, Val, PTR}; const WASM_REL_PATH: &str = "target/wasm32-wasip1/release/vortex_mod_mega.wasm"; @@ -27,133 +22,34 @@ fn wasm_path() -> PathBuf { path } -fn xor_fold_aes_key(raw: &[u8; 32]) -> [u8; 16] { - let mut out = [0u8; 16]; - for i in 0..16 { - out[i] = raw[i] ^ raw[i + 16]; - } - out -} - -/// Build a fake `f` response that ships a single file child whose name is -/// "holiday.mp4", size 12345, encrypted under `master_key`. -fn build_folder_response_body(master_key: &[u8; 16], handle: &str) -> String { - let raw_file_key: [u8; 32] = std::array::from_fn(|i| (i + 1) as u8); - - // ECB-encrypt raw key with master key. - let mut enc_key = raw_file_key; - let aes_master = Aes128::new(master_key.into()); - for block in enc_key.chunks_exact_mut(16) { - aes_master.encrypt_block(GenericArray::from_mut_slice(block)); - } - let enc_key_b64 = URL_SAFE_NO_PAD.encode(enc_key); - - // Recover node AES key (XOR fold of raw key) for attribute encryption. - let node_aes_key = xor_fold_aes_key(&raw_file_key); - let aes_node = Aes128::new(&node_aes_key.into()); - - // Build "MEGA{...}" attribute plaintext, NUL-pad to 16-byte alignment, CBC encrypt with IV=0. - let attr_json = "MEGA{\"n\":\"holiday.mp4\"}"; - let mut attr = attr_json.as_bytes().to_vec(); - while !attr.len().is_multiple_of(16) { - attr.push(0); - } - let mut prev = [0u8; 16]; - for block in attr.chunks_exact_mut(16) { - for (b, p) in block.iter_mut().zip(prev.iter()) { - *b ^= *p; - } - aes_node.encrypt_block(GenericArray::from_mut_slice(block)); - prev = block.try_into().unwrap(); - } - let attr_b64 = URL_SAFE_NO_PAD.encode(&attr); - - let folder_root = "RootRoot"; - let payload = serde_json::json!({ - "f": [ - { "h": folder_root, "p": folder_root, "t": 2 }, - { - "h": handle, "p": folder_root, "t": 0, - "s": 12345_u64, - "k": format!("{folder_root}:{enc_key_b64}"), - "a": attr_b64, - } - ] - }); - payload.to_string() -} - -fn http_envelope(body: &str) -> String { - let body_json = serde_json::Value::String(body.to_string()); - format!(r#"{{"status":200,"headers":{{}},"body":{body_json}}}"#) -} - -fn stub_http_returning(body_static: &'static str) -> Function { +fn stub_http_request() -> Function { Function::new( "http_request", [PTR], [PTR], UserData::<()>::default(), - move |plugin, _inputs, outputs, _user_data: UserData<()>| { - let envelope = http_envelope(body_static); - let handle = plugin.memory_new(&envelope)?; + |plugin, _inputs, outputs, _user_data: UserData<()>| { + let response = r#"{"status":200,"headers":{},"body":"[]"}"#; + let handle = plugin.memory_new(response)?; outputs[0] = Val::I64(handle.offset() as i64); Ok(()) }, ) } -fn load_plugin_with_stub(path: &PathBuf, stub: Function) -> extism::Plugin { - let manifest = extism::Manifest::new([extism::Wasm::file(path)]); - extism::Plugin::new(&manifest, [stub], true).expect("load wasm") -} - -macro_rules! require_wasm { - () => { - wasm_path() - }; -} - #[test] -fn wasm_extract_links_decrypts_folder_listing_with_one_child() { - let path = require_wasm!(); - let master_key: [u8; 16] = std::array::from_fn(|i| (i as u8).wrapping_mul(7)); - let folder_key_b64 = URL_SAFE_NO_PAD.encode(master_key); - assert_eq!( - folder_key_b64.len(), - 22, - "16-byte folder key → 22 b64url chars" - ); - let folder_id = "Fld1d2EF"; - let folder_url = format!("https://mega.nz/folder/{folder_id}#{folder_key_b64}"); - let child_handle = "ChildHnd"; - - // Box::leak: test-only; the synthetic body must outlive the static - // closure required by extism's Function::new. - let body: &'static str = - Box::leak(build_folder_response_body(&master_key, child_handle).into_boxed_str()); - let stub = stub_http_returning(body); - let mut plugin = load_plugin_with_stub(&path, stub); - - let result: String = plugin - .call("extract_links", folder_url.as_str()) - .expect("extract_links call"); - - let v: serde_json::Value = serde_json::from_str(&result).expect("plugin returned JSON"); - assert_eq!(v["kind"], "folder"); - let files = v["files"].as_array().expect("files array"); - assert_eq!(files.len(), 1, "exactly one decrypted file child"); - let f = &files[0]; - assert_eq!(f["id"], child_handle); - assert_eq!(f["filename"], "holiday.mp4"); - assert_eq!(f["size_bytes"].as_u64(), Some(12345)); - assert!(f["url"] - .as_str() - .unwrap() - .starts_with("https://mega.nz/file/ChildHnd#")); +fn wasm_extract_links_refuses_folder_url_until_decryption_ships() { + let path = wasm_path(); + let manifest = extism::Manifest::new([extism::Wasm::file(&path)]); + let mut plugin = + extism::Plugin::new(&manifest, [stub_http_request()], true).expect("load wasm"); + + let folder_url = "https://mega.nz/folder/Fld1d2EF#AAAAAAAAAAAAAAAAAAAAAA"; + let err = plugin + .call::<&str, String>("extract_links", folder_url) + .expect_err("extract_links must refuse folder URLs"); assert!( - f["direct_url"].is_null(), - "folder children carry no resolved CDN URL" + err.to_string().contains("not supported yet"), + "unexpected extract_links error: {err}" ); - assert_eq!(f["encryption"]["scheme"], "mega-aes128-ctr"); } diff --git a/tests/wasm_smoke.rs b/tests/wasm_smoke.rs index 5c7b818..bc575af 100644 --- a/tests/wasm_smoke.rs +++ b/tests/wasm_smoke.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; use extism::{Function, UserData, Val, PTR}; -use serde_json::{json, Value}; +use serde_json::json; const WASM_REL_PATH: &str = "target/wasm32-wasip1/release/vortex_mod_mega.wasm"; const FILE_URL: &str = "https://mega.nz/file/AbCdEfGh#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; @@ -100,20 +100,25 @@ fn wasm_supports_playlist_false_for_file() { } #[test] -fn wasm_extraction_and_resolution_exports_are_callable() { +fn wasm_extraction_and_resolution_exports_refuse_until_decryption_ships() { let path = require_wasm!(); let mut plugin = load_plugin(&path); - let links: String = plugin - .call("extract_links", FILE_URL) - .expect("extract_links call"); - let links: Value = serde_json::from_str(&links).expect("extract_links JSON"); - assert_eq!(links["kind"], "file"); - assert_eq!(links["files"][0]["direct_url"], DIRECT_URL); - assert_eq!(links["files"][0]["size_bytes"], 4_194_304); + // MAT-136 R-04: both download-facing exports must refuse for a valid MEGA + // URL instead of handing the host an encrypted CDN URL. + let err = plugin + .call::<&str, String>("extract_links", FILE_URL) + .expect_err("extract_links must refuse"); + assert!( + err.to_string().contains("not supported yet"), + "unexpected extract_links error: {err}" + ); - let direct_url: String = plugin - .call("resolve_stream_url", json!({ "url": FILE_URL }).to_string()) - .expect("resolve_stream_url call"); - assert_eq!(direct_url, DIRECT_URL); + let err = plugin + .call::("resolve_stream_url", json!({ "url": FILE_URL }).to_string()) + .expect_err("resolve_stream_url must refuse"); + assert!( + err.to_string().contains("not supported yet"), + "unexpected resolve_stream_url error: {err}" + ); }