first commit
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
//! 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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user