diff --git a/Extensions/Firefox b/Extensions/Firefox index b08c765..528810a 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit b08c76517aa6344fb0f7d1479199c623eeef069a +Subproject commit 528810a1907c8e0c111ef8eb894291834b7c972f diff --git a/scripts/engine-payload-integrity.js b/scripts/engine-payload-integrity.js new file mode 100644 index 0000000..8a2589e --- /dev/null +++ b/scripts/engine-payload-integrity.js @@ -0,0 +1,45 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +export function collectRegularFiles(root, options = {}) { + const ignoredNames = new Set(options.ignoredNames || []); + const files = []; + + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + const relative = path.relative(root, file).split(path.sep).join('/'); + if (ignoredNames.has(entry.name)) { + continue; + } + if (entry.isSymbolicLink()) { + throw new Error(`Unsupported symlink in engine payload: ${relative}`); + } + if (entry.isDirectory()) { + walk(file); + } else if (entry.isFile()) { + files.push(file); + } else { + throw new Error(`Unsupported filesystem entry in engine payload: ${relative}`); + } + } + }; + + walk(root); + return files.sort((left, right) => left.localeCompare(right)); +} + +export function treeDigest(root) { + const files = collectRegularFiles(root); + const digest = crypto.createHash('sha256'); + for (const file of files) { + const relative = path.relative(root, file).split(path.sep).join('/'); + digest.update(`${relative}\0${sha256(file)}\n`); + } + return { files: files.length, sha256: digest.digest('hex') }; +} diff --git a/scripts/engine-payload-integrity.test.js b/scripts/engine-payload-integrity.test.js new file mode 100644 index 0000000..92864e9 --- /dev/null +++ b/scripts/engine-payload-integrity.test.js @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { collectRegularFiles, treeDigest } from './engine-payload-integrity.js'; + +test('collectRegularFiles returns regular files and honors ignored names', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-')); + try { + fs.mkdirSync(path.join(root, 'nested')); + fs.writeFileSync(path.join(root, 'engine'), 'binary'); + fs.writeFileSync(path.join(root, 'nested', 'runtime.dat'), 'runtime'); + fs.writeFileSync(path.join(root, 'payload-manifest.json'), '{}'); + + const files = collectRegularFiles(root, { + ignoredNames: ['payload-manifest.json'], + }).map(file => path.relative(root, file).split(path.sep).join('/')); + + assert.deepEqual(files, ['engine', 'nested/runtime.dat']); + const digest = treeDigest(path.join(root, 'nested')); + assert.equal(digest.files, 1); + assert.match(digest.sha256, /^[a-f0-9]{64}$/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('collectRegularFiles rejects symlinks in engine payloads', { skip: process.platform === 'win32' }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-')); + try { + fs.writeFileSync(path.join(root, 'target'), 'target'); + fs.symlinkSync('target', path.join(root, 'link')); + + assert.throws( + () => collectRegularFiles(root), + /Unsupported symlink in engine payload: link/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index 82eb0bb..1e91222 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -1,10 +1,10 @@ #!/usr/bin/env node -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { collectRegularFiles, sha256 } from './engine-payload-integrity.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -36,10 +36,6 @@ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${targ const isWindows = target.includes('windows'); const executableSuffix = isWindows ? '.exe' : ''; -function sha256(file) { - return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); -} - async function download(name, source) { const sourcePath = new URL(source.url).pathname; const archive = path.join( @@ -99,16 +95,9 @@ function copyExecutable(source, engine) { } function writePayloadManifest() { - const files = []; - const walk = directory => { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const file = path.join(directory, entry.name); - if (entry.isDirectory()) walk(file); - else if (entry.isFile() && entry.name !== 'payload-manifest.json') files.push(file); - } - }; - walk(destination); - files.sort((left, right) => left.localeCompare(right)); + const files = collectRegularFiles(destination, { + ignoredNames: ['payload-manifest.json'], + }); const manifest = { schemaVersion: 1, target, diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index 95eed7f..c6e7ddd 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import crypto from 'node:crypto'; +import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -51,29 +51,6 @@ if (!source) { process.exit(1); } -function sha256(file) { - return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); -} - -function treeDigest(root) { - const files = []; - const walk = directory => { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const file = path.join(directory, entry.name); - if (entry.isDirectory()) walk(file); - else if (entry.isFile()) files.push(file); - } - }; - walk(root); - files.sort((left, right) => left.localeCompare(right)); - const digest = crypto.createHash('sha256'); - for (const file of files) { - const relative = path.relative(root, file).split(path.sep).join('/'); - digest.update(`${relative}\0${sha256(file)}\n`); - } - return { files: files.length, sha256: digest.digest('hex') }; -} - if (targetLock) { for (const engine of engines) { const name = `${engine}-${target}${suffix}`; @@ -115,17 +92,9 @@ if (targetLock) { process.exit(1); } } - const actualFiles = []; - const walk = directory => { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const file = path.join(directory, entry.name); - if (entry.isDirectory()) walk(file); - else if (entry.isFile() && entry.name !== 'payload-manifest.json') { - actualFiles.push(path.relative(source, file).split(path.sep).join('/')); - } - } - }; - walk(source); + const actualFiles = collectRegularFiles(source, { + ignoredNames: ['payload-manifest.json'], + }).map(file => path.relative(source, file).split(path.sep).join('/')); const expectedFiles = Object.keys(manifest.files || {}).sort(); actualFiles.sort(); if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index f4703a9..97725c6 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -90,6 +90,31 @@ function ok(msg) { console.log(`[OK] ${msg}`); } +function rejectSymlinks(root, label) { + if (!fs.existsSync(root)) { + return; + } + + let symlinkCount = 0; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + const relative = path.relative(root, file).split(path.sep).join('/'); + if (entry.isSymbolicLink()) { + fail(`Unsupported symlink in ${label}: ${relative}`); + symlinkCount += 1; + } else if (entry.isDirectory()) { + walk(file); + } + } + }; + + walk(root); + if (symlinkCount === 0) { + ok(`${label} contains no symlinks`); + } +} + function binName(engine) { return `${engine}${suffix}`; } @@ -208,23 +233,7 @@ console.log('\n─── 6. yt-dlp packaging ───'); } catch (e) { fail(`Cannot read _internal/: ${e.message}`); } - if (entries) { - let broken = 0; - for (const entry of entries) { - const ep = path.join(internalDir, entry); - if (fs.lstatSync(ep).isSymbolicLink()) { - try { - fs.accessSync(ep); - } catch { - fail(`Broken symlink in _internal/: ${entry}`); - broken++; - } - } - } - if (broken === 0) { - ok('_internal/ symlinks valid'); - } - } + rejectSymlinks(internalDir, '_internal/'); const requiredRuntimeFiles = [ path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'), @@ -253,6 +262,11 @@ console.log('\n─── 6. yt-dlp packaging ───'); } } +const aria2LibsDir = path.join(binariesDir, 'aria2-libs'); +if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) { + rejectSymlinks(aria2LibsDir, 'aria2-libs/'); +} + // ───── Check 7, 8 & 9: Engine version self-tests ───── console.log('\n─── 7 & 8 & 9. Engine version self-tests ───'); diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 37be49e..2c2885f 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -27,7 +27,11 @@ const MAX_URL_COUNT: usize = 200; const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SERVER_HEADER: &str = "x-firelink-server"; const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version"; -const PROTOCOL_VERSION: &str = "2"; +const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce"; +const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; +const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; +const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; +const PROTOCOL_VERSION: &str = "3"; type HmacSha256 = Hmac; pub type SharedExtensionToken = Arc>; @@ -41,6 +45,7 @@ pub struct ServerState { pub pairing_token: SharedExtensionToken, pub frontend_ready: SharedFrontendReady, pub replay_cache: ReplayCache, + pub bound_port: u16, } #[derive(Deserialize)] @@ -76,11 +81,13 @@ pub async fn start_server( server_port: SharedServerPort, mut shutdown_rx: watch::Receiver, ) -> Result<(), String> { + let (port, listener) = bind_extension_listener().await?; let state = ServerState { app_handle, pairing_token, frontend_ready, replay_cache: Arc::new(Mutex::new(HashMap::new())), + bound_port: port, }; let cors = CorsLayer::new() @@ -98,7 +105,6 @@ pub async fn start_server( .layer(middleware::from_fn(add_server_identity)) .with_state(state); - let (port, listener) = bind_extension_listener().await?; if let Ok(mut current_port) = server_port.write() { *current_port = Some(port); } @@ -156,13 +162,13 @@ async fn ping_handler( State(state): State, headers: HeaderMap, body: Bytes, -) -> StatusCode { +) -> Result { let signature = match headers .get("x-firelink-signature") .and_then(|v| v.to_str().ok()) { Some(v) => v, - None => return StatusCode::FORBIDDEN, + None => return Err(StatusCode::FORBIDDEN), }; let timestamp_str = match headers @@ -170,14 +176,41 @@ async fn ping_handler( .and_then(|v| v.to_str().ok()) { Some(v) => v, - None => return StatusCode::FORBIDDEN, + None => return Err(StatusCode::FORBIDDEN), + }; + + let nonce = match headers + .get(CLIENT_NONCE_HEADER) + .and_then(|v| v.to_str().ok()) + .filter(|value| is_valid_client_nonce(value)) + { + Some(v) => v, + None => return Err(StatusCode::FORBIDDEN), }; if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() { - return StatusCode::FORBIDDEN; + return Err(StatusCode::FORBIDDEN); } - StatusCode::OK + let proof = sign_server_proof( + timestamp_str, + nonce, + state.bound_port, + &state.pairing_token, + ) + .map_err(|_| StatusCode::FORBIDDEN)?; + + let mut response = Response::new(Body::empty()); + response.headers_mut().insert( + SERVER_PROOF_HEADER, + HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + response.headers_mut().insert( + SERVER_PORT_HEADER, + HeaderValue::from_str(&state.bound_port.to_string()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + Ok(response) } async fn download_handler( @@ -327,6 +360,39 @@ fn verify_signature( Ok(timestamp) } +fn is_valid_client_nonce(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn sign_server_proof( + timestamp_text: &str, + nonce: &str, + bound_port: u16, + pairing_token: &SharedExtensionToken, +) -> Result { + let token = pairing_token.read().unwrap_or_else(|e| e.into_inner()); + if token.is_empty() { + return Err(()); + } + + let mut mac = HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| ())?; + mac.update(SERVER_PROOF_PREFIX); + mac.update(timestamp_text.as_bytes()); + mac.update(b"\n"); + mac.update(nonce.as_bytes()); + mac.update(b"\n"); + mac.update(bound_port.to_string().as_bytes()); + let signature = mac.finalize().into_bytes(); + Ok(encode_hex(signature.as_slice())) +} + +fn encode_hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() +} + fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) -> bool { let now = match current_time_millis() { Some(now) => now, @@ -383,8 +449,14 @@ fn is_allowed_origin(origin: &str) -> bool { #[cfg(test)] mod tests { - use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER}; + use super::{ + add_server_identity, is_valid_client_nonce, sign_server_proof, PROTOCOL_VERSION_HEADER, + SERVER_HEADER, + }; use axum::{http::StatusCode, middleware, routing::get, Router}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use std::sync::{Arc, RwLock}; #[tokio::test] async fn identifies_every_extension_server_response() { @@ -406,9 +478,48 @@ mod tests { assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!( response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), - "2" + "3" ); server.abort(); } + + #[test] + fn validates_client_nonce_shape() { + assert!(is_valid_client_nonce("0123456789abcdef0123456789abcdef")); + assert!(is_valid_client_nonce("ABCDEF0123456789abcdef0123456789")); + assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcde")); + assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg")); + } + + #[test] + fn signs_server_proof_with_timestamp_nonce_and_bound_port() { + let token = Arc::new(RwLock::new("pairing-token".to_string())); + let timestamp = "1710000000000"; + let nonce = "0123456789abcdef0123456789abcdef"; + let port = 6414; + + let mut mac = Hmac::::new_from_slice(b"pairing-token").unwrap(); + mac.update(b"firelink-server-proof\n"); + mac.update(timestamp.as_bytes()); + mac.update(b"\n"); + mac.update(nonce.as_bytes()); + mac.update(b"\n"); + mac.update(port.to_string().as_bytes()); + let expected = mac + .finalize() + .into_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + assert_eq!( + sign_server_proof(timestamp, nonce, port, &token).unwrap(), + expected + ); + assert_ne!( + sign_server_proof(timestamp, nonce, port + 1, &token).unwrap(), + expected + ); + } }