From 032ff16e25c3ea937b4b1185d25318d0ad716a62 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sat, 29 Apr 2023 19:01:02 +0100 Subject: [PATCH 1/3] Handle WebGL reading of pixels --- src/canvas.js | 83 +++++++++++++++++++++++++-- src/features/fingerprinting-canvas.js | 25 +++++++- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/canvas.js b/src/canvas.js index c25924a79..f7f36a01a 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -1,8 +1,81 @@ import { getDataKeySync } from './crypto.js' import Seedrandom from 'seedrandom' +export function copy2dContextToWebGLContext (ctx2d, ctx3d) { + const canvas2d = ctx2d.canvas + const imageData = ctx2d.getImageData(0, 0, canvas2d.width, canvas2d.height) + const pixelData = new Uint8Array(imageData.data.buffer) + + const texture = ctx3d.createTexture() + ctx3d.bindTexture(ctx3d.TEXTURE_2D, texture) + ctx3d.texImage2D(ctx3d.TEXTURE_2D, 0, ctx3d.RGBA, canvas2d.width, canvas2d.height, 0, ctx3d.RGBA, ctx3d.UNSIGNED_BYTE, pixelData) + ctx3d.texParameteri(ctx3d.TEXTURE_2D, ctx3d.TEXTURE_WRAP_S, ctx3d.CLAMP_TO_EDGE) + ctx3d.texParameteri(ctx3d.TEXTURE_2D, ctx3d.TEXTURE_WRAP_T, ctx3d.CLAMP_TO_EDGE) + ctx3d.texParameteri(ctx3d.TEXTURE_2D, ctx3d.TEXTURE_MIN_FILTER, ctx3d.LINEAR) + ctx3d.texParameteri(ctx3d.TEXTURE_2D, ctx3d.TEXTURE_MAG_FILTER, ctx3d.LINEAR) + + const vertexShaderSource = ` + attribute vec2 a_position; + varying vec2 v_texCoord; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_position * 0.5 + 0.5; + }` + const fragmentShaderSource = ` + precision mediump float; + uniform sampler2D u_texture; + varying vec2 v_texCoord; + + void main() { + gl_FragColor = texture2D(u_texture, v_texCoord); + }` + + const vertexShader = createShader(ctx3d, ctx3d.VERTEX_SHADER, vertexShaderSource) + const fragmentShader = createShader(ctx3d, ctx3d.FRAGMENT_SHADER, fragmentShaderSource) + + const program = ctx3d.createProgram() + ctx3d.attachShader(program, vertexShader) + ctx3d.attachShader(program, fragmentShader) + ctx3d.linkProgram(program) + + if (!ctx3d.getProgramParameter(program, ctx3d.LINK_STATUS)) { + throw new Error('Unable to initialize the shader program: ' + ctx3d.getProgramInfoLog(program)) + } + const positionAttributeLocation = ctx3d.getAttribLocation(program, 'a_position') + const positionBuffer = ctx3d.createBuffer() + ctx3d.bindBuffer(ctx3d.ARRAY_BUFFER, positionBuffer) + const positions = [ + -1.0, -1.0, + 1.0, -1.0, + -1.0, 1.0, + 1.0, 1.0 + ] + ctx3d.bufferData(ctx3d.ARRAY_BUFFER, new Float32Array(positions), ctx3d.STATIC_DRAW) + + ctx3d.useProgram(program) + ctx3d.enableVertexAttribArray(positionAttributeLocation) + ctx3d.vertexAttribPointer(positionAttributeLocation, 2, ctx3d.FLOAT, false, 0, 0) + + const textureUniformLocation = ctx3d.getUniformLocation(program, 'u_texture') + ctx3d.activeTexture(ctx3d.TEXTURE0) + ctx3d.bindTexture(ctx3d.TEXTURE_2D, texture) + ctx3d.uniform1i(textureUniformLocation, 0) + ctx3d.drawArrays(ctx3d.TRIANGLE_STRIP, 0, 4) +} + +function createShader (gl, type, source) { + const shader = gl.createShader(type) + gl.shaderSource(shader, source) + gl.compileShader(shader) + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)) + } + return shader +} + /** - * @param {HTMLCanvasElement} canvas + * @param {HTMLCanvasElement | OffscreenCanvas} canvas * @param {string} domainKey * @param {string} sessionKey * @param {any} getImageDataProxy @@ -19,14 +92,16 @@ export function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageD offScreenCanvas.width = canvas.width offScreenCanvas.height = canvas.height const offScreenCtx = offScreenCanvas.getContext('2d') + // Should not happen, but just in case + if (!offScreenCtx) { + return null + } let rasterizedCtx = ctx // If we're not a 2d canvas we need to rasterise first into 2d const rasterizeToCanvas = !(ctx instanceof CanvasRenderingContext2D) if (rasterizeToCanvas) { - // @ts-expect-error - Type 'CanvasRenderingContext2D | null' is not assignable to type 'CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext'. rasterizedCtx = offScreenCtx - // @ts-expect-error - 'offScreenCtx' is possibly 'null'. offScreenCtx.drawImage(canvas, 0, 0) } @@ -35,11 +110,9 @@ export function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageD imageData = modifyPixelData(imageData, sessionKey, domainKey, canvas.width) if (rasterizeToCanvas) { - // @ts-expect-error - Type 'null' is not assignable to type 'CanvasRenderingContext2D'. clearCanvas(offScreenCtx) } - // @ts-expect-error - 'offScreenCtx' is possibly 'null'. offScreenCtx.putImageData(imageData, 0, 0) return { offScreenCanvas, offScreenCtx } diff --git a/src/features/fingerprinting-canvas.js b/src/features/fingerprinting-canvas.js index 091414239..a78ef2262 100644 --- a/src/features/fingerprinting-canvas.js +++ b/src/features/fingerprinting-canvas.js @@ -1,5 +1,5 @@ import { DDGProxy, DDGReflect } from '../utils' -import { computeOffScreenCanvas } from '../canvas' +import { computeOffScreenCanvas, copy2dContextToWebGLContext } from '../canvas' import ContentFeature from '../content-feature' export default class FingerprintingCanvas extends ContentFeature { @@ -112,6 +112,7 @@ export default class FingerprintingCanvas extends ContentFeature { if ('WebGL2RenderingContext' in globalThis) { glContexts.push(WebGL2RenderingContext) } + const webGLReadMethods = ['readPixels'] for (const context of glContexts) { for (const methodName of unsafeGlMethods) { // Some methods are browser specific @@ -126,6 +127,26 @@ export default class FingerprintingCanvas extends ContentFeature { unsafeProxy.overload() } } + + if (this.getFeatureSettingEnabled('webGlReadMethods')) { + for (const methodName of webGLReadMethods) { + const webGLReadMethodsProxy = new DDGProxy(featureName, context.prototype, methodName, { + apply (target, thisArg, args) { + if (thisArg) { + const { offScreenCanvas, offScreenCtx } = getCachedOffScreenCanvasOrCompute(thisArg.canvas, domainKey, sessionKey) + try { + // Clone the 2d context back into the pages webgl context + copy2dContextToWebGLContext(offScreenCtx, thisArg) + } catch (e) { + console.log('Failed to call readPixels on offscreen canvas', e, target, offScreenCanvas, offScreenCtx, args) + } + } + return DDGReflect.apply(target, thisArg, args) + } + }) + webGLReadMethodsProxy.overload() + } + } } } @@ -153,7 +174,7 @@ export default class FingerprintingCanvas extends ContentFeature { /** * Get cached offscreen if one exists, otherwise compute one * - * @param {HTMLCanvasElement} canvas + * @param {HTMLCanvasElement | OffscreenCanvas} canvas * @param {string} domainKey * @param {string} sessionKey */ From 59a558ada5f517bdb434319c5658a518bc82b0c0 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sat, 29 Apr 2023 19:45:59 +0100 Subject: [PATCH 2/3] Add typing info --- src/canvas.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/canvas.js b/src/canvas.js index f7f36a01a..97f780658 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -1,6 +1,12 @@ import { getDataKeySync } from './crypto.js' import Seedrandom from 'seedrandom' +import { postDebugMessage } from './utils.js' +/** + * Copies the contents of a 2D canvas to a WebGL texture + * @param {CanvasRenderingContext2D} ctx2d + * @param {WebGLRenderingContext | WebGL2RenderingContext} ctx3d + */ export function copy2dContextToWebGLContext (ctx2d, ctx3d) { const canvas2d = ctx2d.canvas const imageData = ctx2d.getImageData(0, 0, canvas2d.width, canvas2d.height) @@ -35,12 +41,18 @@ export function copy2dContextToWebGLContext (ctx2d, ctx3d) { const fragmentShader = createShader(ctx3d, ctx3d.FRAGMENT_SHADER, fragmentShaderSource) const program = ctx3d.createProgram() + // Shouldn't happen but bail happy if it does + if (!program || !vertexShader || !fragmentShader) { + postDebugMessage('Unable to initialize the shader program') + return + } ctx3d.attachShader(program, vertexShader) ctx3d.attachShader(program, fragmentShader) ctx3d.linkProgram(program) if (!ctx3d.getProgramParameter(program, ctx3d.LINK_STATUS)) { - throw new Error('Unable to initialize the shader program: ' + ctx3d.getProgramInfoLog(program)) + // Shouldn't happen but bail happy if it does + postDebugMessage('Unable to initialize the shader program: ' + ctx3d.getProgramInfoLog(program)) } const positionAttributeLocation = ctx3d.getAttribLocation(program, 'a_position') const positionBuffer = ctx3d.createBuffer() @@ -64,12 +76,22 @@ export function copy2dContextToWebGLContext (ctx2d, ctx3d) { ctx3d.drawArrays(ctx3d.TRIANGLE_STRIP, 0, 4) } +/** + * @param {WebGLRenderingContext | WebGL2RenderingContext} gl + * @param {number} type + * @param {string} source + * @returns {WebGLShader | null} + */ function createShader (gl, type, source) { const shader = gl.createShader(type) + if (!shader) { + return null + } gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - throw new Error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)) + postDebugMessage('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)) + return null } return shader } From c23a81b1850bbc27c91b89561b9b9f5507638783 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sun, 30 Apr 2023 15:07:58 +0100 Subject: [PATCH 3/3] Render to a new webgl canvas to ensure re-entrancy isn't an issue --- src/canvas.js | 35 +++++++++++++++++++++++++-- src/features/fingerprinting-canvas.js | 16 ++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/canvas.js b/src/canvas.js index 97f780658..1cd09d94d 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -96,14 +96,23 @@ function createShader (gl, type, source) { return shader } +/** + * @typedef {Object} OffscreenCanvasInfo + * @property {HTMLCanvasElement | OffscreenCanvas} offScreenCanvas + * @property {CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext} offScreenCtx + * @property {WebGL2RenderingContext | WebGLRenderingContext} [offScreenWebGlCtx] + */ + /** * @param {HTMLCanvasElement | OffscreenCanvas} canvas * @param {string} domainKey * @param {string} sessionKey * @param {any} getImageDataProxy * @param {CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext} ctx? + * @param {boolean} [shouldCopy2dContextToWebGLContext] + * @returns {OffscreenCanvasInfo | null} */ -export function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageDataProxy, ctx) { +export function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageDataProxy, ctx, shouldCopy2dContextToWebGLContext = false) { if (!ctx) { // @ts-expect-error - Type 'null' is not assignable to type 'CanvasRenderingContext2D | WebGL2RenderingContext | WebGLRenderingContext'. ctx = canvas.getContext('2d') @@ -137,7 +146,29 @@ export function computeOffScreenCanvas (canvas, domainKey, sessionKey, getImageD offScreenCtx.putImageData(imageData, 0, 0) - return { offScreenCanvas, offScreenCtx } + /** @type {OffscreenCanvasInfo} */ + const output = { offScreenCanvas, offScreenCtx } + if (shouldCopy2dContextToWebGLContext) { + const offScreenWebGlCanvas = document.createElement('canvas') + offScreenWebGlCanvas.width = canvas.width + offScreenWebGlCanvas.height = canvas.height + let offScreenWebGlCtx + if (ctx instanceof WebGLRenderingContext) { + offScreenWebGlCtx = offScreenWebGlCanvas.getContext('webgl') + } else { + offScreenWebGlCtx = offScreenWebGlCanvas.getContext('webgl2') + } + if (offScreenWebGlCtx) { + try { + // Clone the 2d context back into the pages webgl context + copy2dContextToWebGLContext(offScreenCtx, offScreenWebGlCtx) + output.offScreenWebGlCtx = offScreenWebGlCtx + } catch (e) { + postDebugMessage('Failed to call readPixels on offscreen canvas', e) + } + } + } + return output } /** diff --git a/src/features/fingerprinting-canvas.js b/src/features/fingerprinting-canvas.js index a78ef2262..9367e5eea 100644 --- a/src/features/fingerprinting-canvas.js +++ b/src/features/fingerprinting-canvas.js @@ -1,4 +1,4 @@ -import { DDGProxy, DDGReflect } from '../utils' +import { DDGProxy, DDGReflect, postDebugMessage } from '../utils' import { computeOffScreenCanvas, copy2dContextToWebGLContext } from '../canvas' import ContentFeature from '../content-feature' @@ -133,12 +133,9 @@ export default class FingerprintingCanvas extends ContentFeature { const webGLReadMethodsProxy = new DDGProxy(featureName, context.prototype, methodName, { apply (target, thisArg, args) { if (thisArg) { - const { offScreenCanvas, offScreenCtx } = getCachedOffScreenCanvasOrCompute(thisArg.canvas, domainKey, sessionKey) - try { - // Clone the 2d context back into the pages webgl context - copy2dContextToWebGLContext(offScreenCtx, thisArg) - } catch (e) { - console.log('Failed to call readPixels on offscreen canvas', e, target, offScreenCanvas, offScreenCtx, args) + const { offScreenWebGlCtx } = getCachedOffScreenCanvasOrCompute(thisArg.canvas, domainKey, sessionKey, true) + if (offScreenWebGlCtx) { + return DDGReflect.apply(target, offScreenWebGlCtx, args) } } return DDGReflect.apply(target, thisArg, args) @@ -177,14 +174,15 @@ export default class FingerprintingCanvas extends ContentFeature { * @param {HTMLCanvasElement | OffscreenCanvas} canvas * @param {string} domainKey * @param {string} sessionKey + * @returns {import('../canvas').OffscreenCanvasInfo} */ - function getCachedOffScreenCanvasOrCompute (canvas, domainKey, sessionKey) { + function getCachedOffScreenCanvasOrCompute (canvas, domainKey, sessionKey, copy2dContextToWebGLContext) { let result if (canvasCache.has(canvas)) { result = canvasCache.get(canvas) } else { const ctx = canvasContexts.get(canvas) - result = computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx) + result = computeOffScreenCanvas(canvas, domainKey, sessionKey, getImageDataProxy, ctx, copy2dContextToWebGLContext) canvasCache.set(canvas, result) } return result