337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
/** \file
|
|
* \brief This file contains wrappers for Canvas-related calls
|
|
*
|
|
* \see https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
|
|
* \see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
|
|
* \see https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
|
|
*
|
|
* \author Copyright (C) 2019 Libor Polcak
|
|
* \author Copyright (C) 2021 Matus Svancar
|
|
* \author Copyright (C) 2023 Martin Zmitko
|
|
*
|
|
* \license SPDX-License-Identifier: GPL-3.0-or-later
|
|
* \license SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// Alternatively, the contents of this file may be used under the terms
|
|
// of the Mozilla Public License, v. 2.0, as described below:
|
|
//
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
// You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
//
|
|
// \copyright Copyright (c) 2020 The Brave Authors.
|
|
|
|
/** \file
|
|
* \ingroup wrappers
|
|
* This file contains wrappers for calls related to the Canvas API, about which you can read more at MDN:
|
|
* * [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API)
|
|
* * [CanvasRenderingContext2D](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D)
|
|
* * [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas)
|
|
*
|
|
* The goal is to prevent fingerprinting by modifying the values that can be read from the canvas.
|
|
* So the visual content of wrapped canvases as displayed on the screen is the same as intended.
|
|
*
|
|
* The modified content can be either an empty image or a fake image that is modified according to
|
|
* session and domain keys to be different than the original albeit very similar (i.e. the approach
|
|
* inspired by the algorithms created by [Brave Software](https://brave.com) available [here](https://github.com/brave/brave-core/blob/master/chromium_src/third_party/blink/renderer/core/execution_context/execution_context.cc).
|
|
*
|
|
* Note that both approaches are detectable by a fingerprinter that checks if a predetermined image
|
|
* inserted to the canvas is the same as the read one, see [here](https://arkenfox.github.io/TZP/tests/canvasnoise.html) for an example,
|
|
* Nevertheless, the aim of the wrappers is to limit the finerprintability.
|
|
*
|
|
* Also note that a determined fingerprinter can reveal the modifications and consequently uncover
|
|
* the original image. This can be avoided with the approach that completely clears the data stored
|
|
* in the canvas. Use the modifications based on session and domain keys if you want to provide an
|
|
* image that is similar to the original or if you want to produce a fake image that is not
|
|
* obviously spoofed to a naked eye. Otherwise, use the clearing approach.
|
|
*/
|
|
|
|
/*
|
|
* Create private namespace
|
|
*/
|
|
(function() {
|
|
|
|
const DEF_CANVAS_COPY = `
|
|
let canvasCopy = ctx => {
|
|
let {width, height} = ctx.canvas;
|
|
let fake = document.createElement("canvas");
|
|
fake.setAttribute("width", width);
|
|
fake.setAttribute("height", height);
|
|
let stx = fake.getContext("2d");
|
|
let imageData = window.CanvasRenderingContext2D.prototype.getImageData.call(ctx, 0, 0, width, height);
|
|
stx.putImageData(imageData, 0, 0);
|
|
return fake;
|
|
};
|
|
`;
|
|
|
|
/** @var String helping_code.
|
|
* Selects if the canvas should be cleared (1) or a fake image should be created based on session
|
|
* and domain keys (0).
|
|
*/
|
|
var helping_code = `var approach = args[0];`;
|
|
var wrappers = [{
|
|
parent_object: "HTMLCanvasElement.prototype",
|
|
parent_object_property: "toDataURL",
|
|
wrapped_objects: [{
|
|
original_name: "HTMLCanvasElement.prototype.toDataURL",
|
|
wrapped_name: "origToDataURL",
|
|
}],
|
|
helping_code: helping_code,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.HTMLCanvasElement.prototype.toDataURL",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake HTMLCanvasElement.prototype.toDataURL
|
|
* \brief Returns fake canvas content, see CanvasRenderingContext2D.prototype for more details.
|
|
*
|
|
* Internally creates a fake canvas of the same height and width as the original and calls
|
|
* CanvasRenderingContext2D.getImageData() that determines the result. If canvas uses WebGLRenderingContext
|
|
* the content is copied to new canvas using CanvasRenderingContext2D and function toDataURL is called on it.
|
|
*/
|
|
wrapping_function_body: `
|
|
var ctx = this.getContext("2d");
|
|
if(ctx){
|
|
${DEF_CANVAS_COPY}
|
|
return origToDataURL.call(canvasCopy(ctx), ...args);
|
|
}
|
|
else {
|
|
var ctx = this.getContext("webgl2", {preserveDrawingBuffer: true}) ||
|
|
this.getContext("experimental-webgl2", {preserveDrawingBuffer: true}) ||
|
|
this.getContext("webgl", {preserveDrawingBuffer: true}) ||
|
|
this.getContext("experimental-webgl", {preserveDrawingBuffer: true}) ||
|
|
this.getContext("moz-webgl", {preserveDrawingBuffer: true});
|
|
if(ctx){
|
|
var fake = document.createElement("canvas");
|
|
fake.setAttribute("width", this.width);
|
|
fake.setAttribute("height", this.height);
|
|
var stx = fake.getContext("2d");
|
|
stx.drawImage(ctx.canvas, 0, 0);
|
|
return HTMLCanvasElement.prototype.toDataURL.call(fake);
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
{
|
|
parent_object: "CanvasRenderingContext2D.prototype",
|
|
parent_object_property: "getImageData",
|
|
wrapped_objects: [{
|
|
original_name: "CanvasRenderingContext2D.prototype.getImageData",
|
|
wrapped_name: "origGetImageData",
|
|
}],
|
|
helping_code: helping_code + farbleCanvasDataBrave.toString() + `
|
|
var farble = function(context, fake) {
|
|
if(approach === 1){
|
|
fake.fillStyle = "white";
|
|
fake.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
|
return;
|
|
}
|
|
else if(approach === 0){
|
|
const width = context.canvas.width;
|
|
const height = context.canvas.height;
|
|
const imageData = origGetImageData.call(context, 0, 0, width, height);
|
|
const len = imageData.data.length;
|
|
if (wasm.ready && wasm.grow(len)) {
|
|
try {
|
|
farblePixelsWASM();
|
|
} catch (e) {
|
|
console.error("WebAssembly optimized farbling failed, falling back to JavaScript implementation", e);
|
|
farblePixelsJS();
|
|
}
|
|
} else {
|
|
farblePixelsJS();
|
|
}
|
|
// Do not modify the original canvas, always modify the fake canvas.
|
|
// Always farble the whole image so that the farbled data do not depend
|
|
// on the page-specified extraction data rectangle.
|
|
fake.putImageData(imageData, 0, 0);
|
|
|
|
function farblePixelsWASM() {
|
|
wasm.set(imageData.data);
|
|
const crc = wasm.crc16(len);
|
|
const mash = new Mash();
|
|
mash.addData(' ');
|
|
mash.addData(domainHash);
|
|
mash.addData("CanvasFarbling");
|
|
mash.addData(crc);
|
|
wasm.farbleBytes(len, mash.n | 0, true);
|
|
imageData.data.set(wasm.get(len));
|
|
}
|
|
|
|
function farblePixelsJS() {
|
|
const BYTES_PER_ROW = width * 4;
|
|
farbleCanvasDataBrave(function*() {
|
|
let data = imageData.data;
|
|
let offset = 0;
|
|
while (offset < len) {
|
|
yield data.subarray(offset, offset + BYTES_PER_ROW);
|
|
offset += BYTES_PER_ROW;
|
|
}
|
|
}, width);
|
|
}
|
|
}
|
|
};`,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.CanvasRenderingContext2D.prototype.getImageData",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake CanvasRenderingContext2D.prototype.getImageData
|
|
* \brief Returns a fake image data of the same height and width as stored in the original canvas.
|
|
*
|
|
* Internally calls the farbling that select the output which can be either an empty image or
|
|
* a fake image that is modified according to session and domain keys to be different than the
|
|
* original albeit very similar.
|
|
*/
|
|
wrapping_function_body: `
|
|
var fake = document.createElement("canvas");
|
|
fake.setAttribute("width", this.canvas.width);
|
|
fake.setAttribute("height", this.canvas.height);
|
|
var stx = fake.getContext("2d");
|
|
farble(this,stx);
|
|
return origGetImageData.call(stx, ...args);
|
|
`,
|
|
},
|
|
{
|
|
parent_object: "HTMLCanvasElement.prototype",
|
|
parent_object_property: "toBlob",
|
|
wrapped_objects: [{
|
|
original_name: "HTMLCanvasElement.prototype.toBlob",
|
|
wrapped_name: "origToBlob",
|
|
}],
|
|
helping_code: ``,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.HTMLCanvasElement.prototype.toBlob",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake HTMLCanvasElement.prototype.toBlob
|
|
* \brief Returns fake canvas content, see CanvasRenderingContext2D.prototype for more details.
|
|
*
|
|
* Internally creates a fake canvas of the same height and width as the original and calls
|
|
* CanvasRenderingContext2D.getImageData() that detemines the result.
|
|
*/
|
|
wrapping_function_body: `
|
|
${DEF_CANVAS_COPY}
|
|
return origToBlob.call(canvasCopy(this.getContext("2d")), ...args);
|
|
`,
|
|
},
|
|
{
|
|
parent_object: "OffscreenCanvas.prototype",
|
|
parent_object_property: "convertToBlob",
|
|
wrapped_objects: [{
|
|
original_name: "OffscreenCanvas.prototype.convertToBlob",
|
|
wrapped_name: "origConvertToBlob",
|
|
}],
|
|
helping_code: ``,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.OffscreenCanvas.prototype.convertToBlob",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake OffscreenCanvas.prototype.convertToBlob
|
|
* \brief Returns fake canvas content, see CanvasRenderingContext2D.prototype for more details.
|
|
*
|
|
* Internally creates a fake canvas of the same height and width as the original and calls
|
|
* CanvasRenderingContext2D.getImageData() that detemines the result.
|
|
*/
|
|
wrapping_function_body: `
|
|
${DEF_CANVAS_COPY}
|
|
return origConvertToBlob.call(canvasCopy(this.getContext("2d")), ...args);
|
|
`,
|
|
},
|
|
{
|
|
parent_object: "CanvasRenderingContext2D.prototype",
|
|
parent_object_property: "isPointInPath",
|
|
wrapped_objects: [{
|
|
original_name: "CanvasRenderingContext2D.prototype.isPointInPath",
|
|
wrapped_name: "origIsPointInPath",
|
|
}],
|
|
helping_code: helping_code + `
|
|
function farbleIsPointInPath(ctx, ...args){
|
|
if(approach === 0){
|
|
var ret = origIsPointInPath.call(ctx, ...args);
|
|
return (ret && ((prng()*20) > 1));
|
|
}
|
|
else if(approach === 1){
|
|
return false;
|
|
}
|
|
};
|
|
`,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.CanvasRenderingContext2D.prototype.isPointInPath",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake CanvasRenderingContext2D.prototype.isPointInPath
|
|
* \brief Returns modified result
|
|
*
|
|
* Either returns false or original function return value which is changed to false with 1/20 probability
|
|
*
|
|
* \bug Changing value with probability has some issues:
|
|
* * multiple calls with the same pixel can return different values
|
|
* * inconsistencies among adjacent pixels
|
|
*/
|
|
wrapping_function_body: `
|
|
return farbleIsPointInPath(this, ...args);
|
|
`,
|
|
},
|
|
{
|
|
parent_object: "CanvasRenderingContext2D.prototype",
|
|
parent_object_property: "isPointInStroke",
|
|
wrapped_objects: [{
|
|
original_name: "CanvasRenderingContext2D.prototype.isPointInStroke",
|
|
wrapped_name: "origIsPointInStroke",
|
|
}],
|
|
helping_code: helping_code + `
|
|
function farbleIsPointInStroke(ctx, ...args){
|
|
if(approach === 0){
|
|
var ret = origIsPointInStroke.call(ctx, ...args);
|
|
return (ret && ((prng()*20) > 1));
|
|
}
|
|
else if(approach === 1){
|
|
return false;
|
|
}
|
|
};
|
|
`,
|
|
wrapping_code_function_name: "wrapping",
|
|
wrapping_code_function_params: "parent",
|
|
wrapping_code_function_call_window: true,
|
|
original_function: "parent.CanvasRenderingContext2D.prototype.isPointInStroke",
|
|
replace_original_function: true,
|
|
wrapping_function_args: "...args",
|
|
/** \fn fake CanvasRenderingContext2D.prototype.isPointInStroke
|
|
* \brief Returns modified result
|
|
*
|
|
* Either returns false or original function return value which is changed to false with 1/20 probability
|
|
*
|
|
* \bug Changing value with probability has some issues:
|
|
* * multiple calls with the same pixel can return different values
|
|
* * inconsistencies among adjacent pixels
|
|
*/
|
|
wrapping_function_body: `
|
|
return farbleIsPointInStroke(this, ...args);
|
|
`,
|
|
},
|
|
]
|
|
add_wrappers(wrappers);
|
|
})();
|