297 lines
11 KiB
Rust
297 lines
11 KiB
Rust
//! Construção do script de inicialização do Chromium.
|
|
//!
|
|
//! A função [`build_init_script`] retorna um JavaScript único, interpolado a
|
|
//! partir de uma [`Persona`], injetado via `Page.addScriptToEvaluateOnNewDocument`
|
|
//! antes de qualquer recurso da página. O script padroniza propriedades do
|
|
//! `navigator`, `screen` e os contextos de canvas/WebGL/áudio com os valores da
|
|
//! persona, aplicando perturbação determinística (seed fixa por sessão) sobre os
|
|
//! dados de canvas e áudio. O resultado é uma impressão digital estável dentro
|
|
//! da sessão e distinta entre sessões, sem bloquear as APIs correspondentes.
|
|
|
|
use crate::persona::Persona;
|
|
|
|
/// Constrói o script de inicialização para a sessão. A string resultante é
|
|
/// avaliada antes de qualquer outro script da página.
|
|
pub fn build_init_script(persona: &Persona) -> String {
|
|
let platform = persona.platform.js_platform();
|
|
let plugins = js_array_string(persona.plugins.iter().copied());
|
|
format!(
|
|
r###"(function() {{
|
|
'use strict';
|
|
|
|
const PERSONA = {{
|
|
platform: {platform_js},
|
|
languages: {languages},
|
|
locale: {locale_js},
|
|
hardwareConcurrency: {hw},
|
|
deviceMemory: {dm},
|
|
screenWidth: {sw},
|
|
screenHeight: {sh},
|
|
scale: {scale},
|
|
webglVendor: {webgl_vendor_js},
|
|
webglRenderer: {webgl_renderer_js},
|
|
audioSampleRate: {asr},
|
|
canvasSeed: {canvas_seed},
|
|
audioSeed: {audio_seed},
|
|
plugins: {plugins}
|
|
}};
|
|
|
|
// --- navigator ---
|
|
try {{
|
|
Object.defineProperty(navigator, 'webdriver', {{ get: () => false, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'platform', {{ get: () => PERSONA.platform, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'languages', {{ get: () => PERSONA.languages, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'language', {{ get: () => PERSONA.locale, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => PERSONA.hardwareConcurrency, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'deviceMemory', {{ get: () => PERSONA.deviceMemory, configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'plugins', {{ get: () => buildPlugins(PERSONA.plugins), configurable: true }});
|
|
}} catch (e) {{}}
|
|
try {{
|
|
Object.defineProperty(navigator, 'mimeTypes', {{ get: () => buildMimeTypes(), configurable: true }});
|
|
}} catch (e) {{}}
|
|
|
|
// --- screen ---
|
|
try {{
|
|
Object.defineProperty(screen, 'width', {{ get: () => PERSONA.screenWidth, configurable: true }});
|
|
Object.defineProperty(screen, 'height', {{ get: () => PERSONA.screenHeight, configurable: true }});
|
|
Object.defineProperty(screen, 'availWidth', {{ get: () => PERSONA.screenWidth, configurable: true }});
|
|
Object.defineProperty(screen, 'availHeight', {{ get: () => PERSONA.screenHeight - 40, configurable: true }});
|
|
Object.defineProperty(window, 'outerWidth', {{ get: () => PERSONA.screenWidth, configurable: true }});
|
|
Object.defineProperty(window, 'outerHeight', {{ get: () => PERSONA.screenHeight, configurable: true }});
|
|
}} catch (e) {{}}
|
|
|
|
// --- canvas: perturbação determinística de 1 bit por canal ---
|
|
function mulberry32(seed) {{
|
|
let a = seed >>> 0;
|
|
return function() {{
|
|
a = (a + 0x6D2B79F5) >>> 0;
|
|
let t = a;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
}};
|
|
}}
|
|
const canvasRng = mulberry32(PERSONA.canvasSeed);
|
|
|
|
function perturbPixels(imageData) {{
|
|
const data = imageData.data;
|
|
for (let i = 0; i < data.length; i += 4) {{
|
|
const r = canvasRng();
|
|
if (r < 0.25) data[i] = data[i] ^ 1;
|
|
if (r > 0.33 && r < 0.58) data[i + 1] = data[i + 1] ^ 1;
|
|
if (r > 0.66 && r < 0.91) data[i + 2] = data[i + 2] ^ 1;
|
|
}}
|
|
return imageData;
|
|
}}
|
|
|
|
try {{
|
|
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
HTMLCanvasElement.prototype.toDataURL = function(...args) {{
|
|
const ctx = this.getContext('2d');
|
|
if (ctx) {{
|
|
try {{
|
|
const w = this.width, h = this.height;
|
|
const img = ctx.getImageData(0, 0, w, h);
|
|
perturbPixels(img);
|
|
ctx.putImageData(img, 0, 0);
|
|
}} catch (e) {{}}
|
|
}}
|
|
return origToDataURL.apply(this, args);
|
|
}};
|
|
}} catch (e) {{}}
|
|
|
|
try {{
|
|
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
|
|
CanvasRenderingContext2D.prototype.getImageData = function(...args) {{
|
|
const img = origGetImageData.apply(this, args);
|
|
return perturbPixels(img);
|
|
}};
|
|
}} catch (e) {{}}
|
|
|
|
// --- WebGL: vendor e renderer coerentes com a persona ---
|
|
try {{
|
|
const overrideGetParameter = function(proto) {{
|
|
const orig = proto.getParameter;
|
|
proto.getParameter = function(parameter) {{
|
|
if (parameter === 37445) return PERSONA.webglVendor;
|
|
if (parameter === 37446) return PERSONA.webglRenderer;
|
|
return orig.apply(this, [parameter]);
|
|
}};
|
|
}};
|
|
if (typeof WebGLRenderingContext !== 'undefined') overrideGetParameter(WebGLRenderingContext.prototype);
|
|
if (typeof WebGL2RenderingContext !== 'undefined') overrideGetParameter(WebGL2RenderingContext.prototype);
|
|
}} catch (e) {{}}
|
|
|
|
// --- AudioContext: ruído determinístico de baixa amplitude ---
|
|
const audioRng = mulberry32(PERSONA.audioSeed);
|
|
try {{
|
|
const origGetByteFrequencyData = AnalyserNode.prototype.getByteFrequencyData;
|
|
AnalyserNode.prototype.getByteFrequencyData = function(array) {{
|
|
origGetByteFrequencyData.apply(this, [array]);
|
|
for (let i = 0; i < array.length; i++) {{
|
|
const n = (audioRng() - 0.5) * 2;
|
|
if (Math.abs(n) < 0.5) array[i] = Math.max(0, Math.min(255, array[i] + (n > 0 ? 1 : -1)));
|
|
}}
|
|
}};
|
|
}} catch (e) {{}}
|
|
|
|
try {{
|
|
const origGetChannelData = AudioBuffer.prototype.getChannelData;
|
|
AudioBuffer.prototype.getChannelData = function(channel) {{
|
|
const data = origGetChannelData.apply(this, [channel]);
|
|
const noise = new Float32Array(data.length);
|
|
for (let i = 0; i < data.length; i++) {{
|
|
noise[i] = (audioRng() - 0.5) * 1e-7;
|
|
}}
|
|
for (let i = 0; i < data.length; i++) {{
|
|
data[i] = data[i] + (i % 2 === 0 ? noise[i] : -noise[i]);
|
|
}}
|
|
return data;
|
|
}};
|
|
}} catch (e) {{}}
|
|
|
|
// --- helpers de plugins/mimeTypes ---
|
|
function makePlugin(name) {{
|
|
const plugin = Object.create(Plugin.prototype);
|
|
Object.defineProperties(plugin, {{
|
|
name: {{ value: name, enumerable: true }},
|
|
filename: {{ value: 'internal-pdf-viewer', enumerable: true }},
|
|
description: {{ value: 'Portable Document Format', enumerable: true }},
|
|
length: {{ value: 1, enumerable: true }},
|
|
0: {{ value: {{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }}, enumerable: true }}
|
|
}});
|
|
return plugin;
|
|
}}
|
|
function buildPlugins(names) {{
|
|
const arr = Object.create(PluginArray.prototype);
|
|
names.forEach((name, idx) => {{
|
|
Object.defineProperty(arr, idx, {{ value: makePlugin(name), enumerable: true, configurable: true }});
|
|
}});
|
|
Object.defineProperty(arr, 'length', {{ value: names.length, enumerable: true }});
|
|
return arr;
|
|
}}
|
|
function makeMime() {{
|
|
const m = Object.create(MimeType.prototype);
|
|
Object.defineProperties(m, {{
|
|
type: {{ value: 'application/pdf', enumerable: true }},
|
|
suffixes: {{ value: 'pdf', enumerable: true }},
|
|
description: {{ value: 'Portable Document Format', enumerable: true }}
|
|
}});
|
|
return m;
|
|
}}
|
|
function buildMimeTypes() {{
|
|
const arr = Object.create(MimeTypeArray.prototype);
|
|
const m = makeMime();
|
|
Object.defineProperty(arr, 0, {{ value: m, enumerable: true, configurable: true }});
|
|
Object.defineProperty(arr, 'length', {{ value: 1, enumerable: true }});
|
|
return arr;
|
|
}}
|
|
}})();
|
|
"###,
|
|
platform_js = js_string(platform),
|
|
languages = js_array_string([persona.locale.as_str(), "en-US", "en"]),
|
|
locale_js = js_string(&persona.locale),
|
|
hw = persona.hardware_concurrency,
|
|
dm = persona.device_memory,
|
|
sw = persona.screen.0,
|
|
sh = persona.screen.1,
|
|
scale = persona.device_scale_factor,
|
|
webgl_vendor_js = js_string(persona.webgl_vendor),
|
|
webgl_renderer_js = js_string(persona.webgl_renderer),
|
|
asr = persona.audio_sample_rate,
|
|
canvas_seed = persona.canvas_seed,
|
|
audio_seed = persona.audio_seed,
|
|
plugins = plugins
|
|
)
|
|
}
|
|
|
|
fn js_string(value: &str) -> String {
|
|
let escaped = value
|
|
.replace('\\', "\\\\")
|
|
.replace('\'', "\\'")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r");
|
|
format!("'{escaped}'")
|
|
}
|
|
|
|
fn js_array_string<I, S>(items: I) -> String
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
let parts: Vec<String> = items
|
|
.into_iter()
|
|
.map(|item| js_string(item.as_ref()))
|
|
.collect();
|
|
format!("[{}]", parts.join(", "))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::persona;
|
|
|
|
#[test]
|
|
fn build_init_script_is_non_empty_and_contains_persona_values() {
|
|
let persona = persona::generate("test", Some("br"));
|
|
let script = build_init_script(&persona);
|
|
assert!(script.len() > 1_000);
|
|
assert!(script.contains(&format!(
|
|
"hardwareConcurrency: {}",
|
|
persona.hardware_concurrency
|
|
)));
|
|
assert!(script.contains(&format!("deviceMemory: {}", persona.device_memory)));
|
|
assert!(script.contains(&format!("screenWidth: {}", persona.screen.0)));
|
|
assert!(script.contains(persona.webgl_vendor));
|
|
assert!(script.contains(persona.webgl_renderer));
|
|
assert!(script.contains(persona.platform.js_platform()));
|
|
}
|
|
|
|
/// Verifica sintaxe do JavaScript injetado via `node --check`. Ignorado
|
|
/// por padrão (exige node no PATH); rodar com `cargo test -- --ignored`.
|
|
#[test]
|
|
#[ignore]
|
|
fn build_init_script_is_valid_javascript_syntax() {
|
|
let persona = persona::generate("test", Some("br"));
|
|
let script = build_init_script(&persona);
|
|
let mut node = match std::process::Command::new("node")
|
|
.arg("--check")
|
|
.arg("-")
|
|
.stdin(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
{
|
|
Ok(child) => child,
|
|
Err(_) => {
|
|
eprintln!("node não disponível; pulando validação de sintaxe JS");
|
|
return;
|
|
}
|
|
};
|
|
use std::io::Write;
|
|
if let Some(stdin) = node.stdin.as_mut() {
|
|
stdin
|
|
.write_all(script.as_bytes())
|
|
.expect("escrita no stdin");
|
|
}
|
|
let output = node.wait_with_output().expect("esperar node terminar");
|
|
assert!(
|
|
output.status.success(),
|
|
"Sintaxe JS inválida:\n--- stderr ---\n{}\n--- início do script ---\n{}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
&script[..script.len().min(800)]
|
|
);
|
|
}
|
|
}
|