Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "vortex-mod-mega"
version = "1.0.0"
version = "1.1.0"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
# 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/<id>#<key>`) — 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/<id>#<key>`) — 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/<id>#<key>`)
- 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

| Plugin function | Behaviour |
|----------------------|-----------------------------------------------------------------------|
| `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/<h>#<k>` 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

Expand Down
4 changes: 2 additions & 2 deletions plugin.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[plugin]
name = "vortex-mod-mega"
version = "1.0.0"
version = "1.1.0"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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"

Expand Down
10 changes: 10 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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!(
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: According to linked Linear issue MAT-136 (R-04), unavailable MEGA downloads must be presented honestly; this new contract is not propagated to the README or WASM smoke test, leaving users with the old capability description and CI with assertions for the superseded behavior. Updating the contract documentation and test expectations alongside this change would keep the release surface consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib.rs, line 6:

<comment>According to linked Linear issue MAT-136 (R-04), unavailable MEGA downloads must be presented honestly; this new contract is not propagated to the README or WASM smoke test, leaving users with the old capability description and CI with assertions for the superseded behavior. Updating the contract documentation and test expectations alongside this change would keep the release surface consistent.</comment>

<file context>
@@ -3,8 +3,8 @@
 //! - `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)
 //!
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — CI was red on exactly this: folder_e2e and the wasm_smoke extraction test still asserted the old contract. Fixed in 650b0c6: the README contract table and status now describe the refusal, wasm_smoke asserts both exports refuse a valid file URL, and folder_e2e is slimmed to a wasm-boundary check that folder URLs refuse too (the synthetic decryption fixture stays in git history for the rewire).

//! 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`,
Expand Down
76 changes: 13 additions & 63 deletions src/plugin_api.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
Expand All @@ -29,29 +15,17 @@ pub fn supports_playlist(url: String) -> FnResult<String> {
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<String> {
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]
Expand All @@ -62,32 +36,8 @@ pub fn resolve_stream_url(input: String) -> FnResult<String> {
}
let params: Input =
serde_json::from_str(&input).map_err(|e| error_to_fn_error(PluginError::SerdeJson(e)))?;
let kind = ensure_supported_url(&params.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<crate::api_client::MegaFileResolution> {
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<String> {
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(&params.url).map_err(error_to_fn_error)?;
Err(error_to_fn_error(PluginError::DecryptionNotSupported))
}

fn error_to_fn_error(err: PluginError) -> WithReturnCode<extism_pdk::Error> {
Expand Down
144 changes: 20 additions & 124 deletions tests/folder_e2e.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
}
Loading