Skip to content

Commit

Permalink
Make the entropy sources run in the "load" phase (fingerprintjs#666)
Browse files Browse the repository at this point in the history
Squashed commit of the following:

commit c72bda0
Merge: 89254a2 feb5531
Author: Sergey M <[email protected]>
Date:   Mon Jun 14 18:04:23 2021 +1000

    Merge branch 'master' into feature/load-signals

commit feb5531
Author: Sergey M <[email protected]>
Date:   Mon Jun 14 18:02:23 2021 +1000

    Handle not numeric screen resolution (fingerprintjs#670)

commit f61e348
Author: Sergey M <[email protected]>
Date:   Mon Jun 14 17:59:44 2021 +1000

    Export isAndroid from the Node package (fingerprintjs#671)

commit 1b9dfda
Author: Martin Makarský <[email protected]>
Date:   Fri Jun 11 08:11:23 2021 +0200

    Add a Discord badge (fingerprintjs#669)

commit fed4180
Author: Martin Makarský <[email protected]>
Date:   Thu Jun 10 12:50:09 2021 +0200

    Add a code of conduct (fingerprintjs#668)

commit 89254a2
Author: Sergey M <[email protected]>
Date:   Thu Jun 10 13:49:18 2021 +1000

    Docs amendments

commit 29adc0a
Author: Valentin V <[email protected]>
Date:   Wed Jun 9 19:45:41 2021 +0300

    Add configuration file for CodeQL
    
    This will run security analysis for common vulnerabilities and problems.

commit ba86b82
Author: Sergey M <[email protected]>
Date:   Wed Jun 9 19:24:47 2021 +1000

    Amendments

commit 0293556
Author: Sergey M <[email protected]>
Date:   Mon Jun 7 10:47:40 2021 +1000

    Fix the documentation issues

commit 665b7da
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Sat Jun 5 09:52:56 2021 +1000

    Bump ws from 6.2.1 to 6.2.2 (fingerprintjs#667)
    
    Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
    - [Release notes](https://github.com/websockets/ws/releases)
    - [Commits](https://github.com/websockets/ws/commits)
    
    ---
    updated-dependencies:
    - dependency-name: ws
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 6a7a994
Author: Sergey M <[email protected]>
Date:   Fri Jun 4 16:36:58 2021 +1000

    Add documentation for the new entropy source system

commit 282f2c4
Author: Sergey M <[email protected]>
Date:   Tue Jun 1 13:56:24 2021 +1000

    Extract the entropy source utilities to a separate file

commit c317f61
Author: Sergey M <[email protected]>
Date:   Mon May 31 21:02:22 2021 +1000

    Stabilize the agent source loading test

commit 7f9c296
Author: Sergey M <[email protected]>
Date:   Mon May 31 20:29:56 2021 +1000

    Remove the old components

commit 3dd2af2
Author: Sergey M <[email protected]>
Date:   Mon May 31 19:38:32 2021 +1000

    Simplify the audio source implementation significantly

commit aa08d8c
Author: Sergey M <[email protected]>
Date:   Mon May 31 18:50:27 2021 +1000

    Adjust all the signals to the new format

commit 98fa057
Author: Sergey M <[email protected]>
Date:   Mon May 31 16:12:26 2021 +1000

    Add a test that checks that agent loads the signals when loaded; allow signals return the result in the "load" phase

commit 71f2123
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Mon May 31 11:07:49 2021 +1000

    Bump dns-packet from 1.3.1 to 1.3.4 (fingerprintjs#662)
    
    Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
    - [Release notes](https://github.com/mafintosh/dns-packet/releases)
    - [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
    - [Commits](mafintosh/dns-packet@v1.3.1...v1.3.4)
    
    Signed-off-by: dependabot[bot] <[email protected]>
    
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 67fa892
Author: Sergey M <[email protected]>
Date:   Fri May 28 21:11:44 2021 +1000

    Add tests for the base code

commit 1698c0c
Author: Sergey M <[email protected]>
Date:   Thu May 27 19:53:36 2021 +1000

    Draft the new entropy source resolution mechanism
  • Loading branch information
Finesse committed Jun 14, 2021
1 parent feb5531 commit 05c801f
Show file tree
Hide file tree
Showing 18 changed files with 1,077 additions and 333 deletions.
111 changes: 111 additions & 0 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,117 @@ To check that the package is compatible with server side rendering, build the pr
yarn test:ssr
```

### How to make an entropy source

Entropy source is a function that gets a piece of data about the browser a.k.a. a fingerprint component.
Entropy sources are located in the [src/sources](src/sources) directory.
Entropy component must be a simple JS value that can be JSON-encoded.

Entropy source runs in 2 stages: "load" and "get":
- "Load" runs once when the agent's `load` function is called.
It must do as much work on the source as possible to make the "get" phase as fast as possible.
It may start background processes that will run indefinitely or until the "get" phase runs.
- "Get" runs once per each agent's `get` function call.
It must be as fast as possible.

An example entropy source:

```js
// The function below represents the "load" phase
async function entropySource() {
// The "load" phase starts here
const preData = await doLongAction()

// The "load" phase ends when the `entropySource` function returns
// The function below represents the "get" phase
return async () => {
// The "get" phase starts here
const finalData = await finalizeData(preData)
return finalData
// The "get" phase ends then this returned function returns
}
}
```

Any of the phases can be synchronous:

```js
function entropySource() {
const preData = doLongSynchronousAction()

return () => {
const finalData = finalizeDataSynchronously(preData)
return finalData
}
}
```

The "get" phase can be omitted:

```js
async function entropySource() {
const finalData = await doLongAction()

// If the source's returned value isn't a function, it's considered as a fingerprint component
return finalData // Equivalent to: return () => finalData
}
```

In fact, most entropy sources don't require a "get" phase.
The "get" phase is required if the component can change after completing the load phase.

In order for agent to measure the entropy source execution duration correctly,
the "load" phase shouldn't run in background (after the source function returns).
On the other hand, in order not to block the whole agent, the "load" phase must contain only the necessary actions.
Example:

```js
async function entropySource() {
// Wait for the required data to be calculated during the "load" phase
let result = await doLongAction()

// Start watching optional data in background (this function doesn't block the execution)
watchNextResults((newResult) => {
result = newResult
})

// Then complete the "load" phase by returning a function.
// `watchNextResults` will continue working until the "get" phase starts.
return () => {
return result
}
}
```

Entropy source must handle expected and only expected errors.
The expected errors must be turned into fingerprint components.
Pay attention to potential asynchronous errors.
If you handle unexpected errors, you won't know what's going wrong inside the entropy source.
Example:

```js
async function entropySource() {
try {
// `await` is necessary to catch asynchronous errors
return await doLongAction()
} catch (error) {
// WRONG:
return 'error'

// Correct:
if (error.message = 'Foo bar') {
return 'bot'
}
if (/boo/.test(error.message)) {
return 'ie'
}
throw error // Unexpected error
}
}
```

When you complete an entropy source, add it to [src/sources/index.ts](src/sources/index.ts).

### How to publish

This section is for repository maintainers.
Expand Down
6 changes: 3 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,18 @@ const FingerprintJS = require('@fingerprintjs/fingerprintjs')

## API

#### `FingerprintJS.load({ delayFallback?: number }): Promise<Agent>`
#### `FingerprintJS.load({ delayFallback?: number, debug?: boolean }): Promise<Agent>`

Builds an instance of Agent and waits a delay required for a proper operation.
We recommend calling it as soon as possible.
`delayFallback` is an optional parameter that sets duration (milliseconds) of the fallback for browsers that don't support [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback);
it has a good default value which we don't recommend to change.
`debug: true` prints debug messages to the console.

#### `agent.get({ debug?: boolean }): Promise<object>`
#### `agent.get(): Promise<object>`

A method of an Agent instance that gets the visitor identifier.
We recommend calling it later, when you really need the identifier, to increase the chance of getting an accurate identifier.
`debug: true` prints debug messages to the console.
Result object fields:

- `visitorId` The visitor identifier
Expand Down
4 changes: 2 additions & 2 deletions playground/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import * as FingerprintJS from '../src'
import { errorToObject } from '../src/utils/misc'

async function getVisitorData() {
const fp = await FingerprintJS.load()
return await fp.get({ debug: true })
const fp = await FingerprintJS.load({ debug: true })
return await fp.get()
}

async function startPlayground() {
Expand Down
40 changes: 18 additions & 22 deletions src/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { version } from '../package.json'
import { withMockProperties } from '../tests/utils'
import { Agent, OpenAgent } from './agent'
import { load as loadAgent } from './agent'
import { sources } from './sources'
import { resetScreenFrameWatch } from './sources/screen_frame'
import { hasScreenFrameBackup, resetScreenFrameWatch } from './sources/screen_frame'
import { wait } from './utils/async'

describe('Agent', () => {
it('collects all components without unexpected errors and makes visitorId', async () => {
const agent = new OpenAgent()
const agent = await loadAgent({ delayFallback: 0 })
const result = await agent.get()
expect(typeof result.visitorId).toBe('string')
expect(result.visitorId).not.toEqual('')
Expand All @@ -22,9 +23,9 @@ describe('Agent', () => {
}
})

it('watches screen frame before calling `get()`', async () => {
it('loads entropy sources when created', async () => {
// Checking whether agent loads entropy sources when created by checking whether the screen frame is watched
resetScreenFrameWatch()
let agent: Agent

await withMockProperties(
screen,
Expand All @@ -37,24 +38,19 @@ describe('Agent', () => {
availTop: { get: () => 0 },
},
async () => {
agent = new OpenAgent()
},
)
const agent = await loadAgent({ delayFallback: 0 })
let areSourcesLoaded = false

// Emulates turning on UI fullscreen in Chrome on macOS
await withMockProperties(
screen,
{
width: { get: () => 1200 },
height: { get: () => 800 },
availWidth: { get: () => 1200 },
availHeight: { get: () => 800 },
availLeft: { get: () => 0 },
availTop: { get: () => 0 },
},
async () => {
const result = await agent.get()
expect(result.components.screenFrame.value).toEqual([0, 0, 40, 100])
// The screen frame source may be not loaded yet at this moment of time, so we need to wait
for (let i = 0; i < 20 && !areSourcesLoaded; ++i) {
if (hasScreenFrameBackup()) {
areSourcesLoaded = true
}
await wait(50)
}

expect(areSourcesLoaded).withContext('Entropy sources are not loaded').toBeTrue()
await agent.get() // To wait until the background processes complete
},
)
})
Expand Down
72 changes: 43 additions & 29 deletions src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { version } from '../package.json'
import { requestIdleCallbackIfAvailable } from './utils/async'
import { UnknownComponents } from './utils/entropy_source'
import { x64hash128 } from './utils/hashing'
import { errorToObject } from './utils/misc'
import getBuiltinComponents, { BuiltinComponents, UnknownComponents } from './sources'
import { watchScreenFrame } from './sources/screen_frame'
import loadBuiltinSources, { BuiltinComponents } from './sources'

/**
* Options for Fingerprint class loading
Expand All @@ -15,6 +15,11 @@ export interface LoadOptions {
* @default 50
*/
delayFallback?: number
/**
* Whether to print debug messages to the console.
* Required to ease investigations of problems.
*/
debug?: boolean
}

/**
Expand All @@ -23,7 +28,8 @@ export interface LoadOptions {
export interface GetOptions {
/**
* Whether to print debug messages to the console.
* Required to ease investigations of problems.
*
* @deprecated Use the `debug` option of `load()` instead
*/
debug?: boolean
}
Expand Down Expand Up @@ -113,48 +119,56 @@ function makeLazyGetResult<T extends UnknownComponents>(components: T) {
}

/**
* The class isn't exported from the index file to not expose the constructor.
* A delay is required to ensure consistent entropy components.
* See https://github.com/fingerprintjs/fingerprintjs/issues/254
* and https://github.com/fingerprintjs/fingerprintjs/issues/307
* and https://github.com/fingerprintjs/fingerprintjs/commit/945633e7c5f67ae38eb0fea37349712f0e669b18
*/
export function prepareForSources(delayFallback = 50): Promise<void> {
// A proper deadline is unknown. Let it be twice the fallback timeout so that both cases have the same average time.
return requestIdleCallbackIfAvailable(delayFallback, delayFallback * 2)
}

/**
* The function isn't exported from the index file to not allow to call it without `load()`.
* The hiding gives more freedom for future non-breaking updates.
*
* A factory function is used instead of a class to shorten the attribute names in the minified code.
* Native private class fields could've been used, but TypeScript doesn't allow them with `"target": "es5"`.
*/
export class OpenAgent implements Agent {
constructor() {
watchScreenFrame()
}
function makeAgent(getComponents: () => Promise<BuiltinComponents>, debug?: boolean): Agent {
const creationTime = Date.now()

/**
* @inheritDoc
*/
public async get(options: Readonly<GetOptions> = {}): Promise<GetResult> {
const components = await getBuiltinComponents(options)
const result = makeLazyGetResult(components)
return {
async get(options) {
const startTime = Date.now()
const components = await getComponents()
const result = makeLazyGetResult(components)

if (options.debug) {
// console.log is ok here because it's under a debug clause
// eslint-disable-next-line no-console
console.log(`Copy the text below to get the debug data:
if (debug || options?.debug) {
// console.log is ok here because it's under a debug clause
// eslint-disable-next-line no-console
console.log(`Copy the text below to get the debug data:
\`\`\`
version: ${result.version}
userAgent: ${navigator.userAgent}
getOptions: ${JSON.stringify(options, undefined, 2)}
timeBetweenLoadAndGet: ${startTime - creationTime}
visitorId: ${result.visitorId}
components: ${componentsToDebugString(components)}
\`\`\``)
}
}

return result
return result
},
}
}

/**
* Builds an instance of Agent and waits a delay required for a proper operation.
*/
export async function load({ delayFallback = 50 }: Readonly<LoadOptions> = {}): Promise<Agent> {
// A delay is required to ensure consistent entropy components.
// See https://github.com/fingerprintjs/fingerprintjs/issues/254
// and https://github.com/fingerprintjs/fingerprintjs/issues/307
// and https://github.com/fingerprintjs/fingerprintjs/commit/945633e7c5f67ae38eb0fea37349712f0e669b18
// A proper deadline is unknown. Let it be twice the fallback timeout so that both cases have the same average time.
await requestIdleCallbackIfAvailable(delayFallback, delayFallback * 2)
return new OpenAgent()
export async function load({ delayFallback, debug }: Readonly<LoadOptions> = {}): Promise<Agent> {
await prepareForSources(delayFallback)
const getComponents = loadBuiltinSources({ debug })
return makeAgent(getComponents, debug)
}
11 changes: 7 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { x64hash128 } from './utils/hashing'
import { load, Agent, LoadOptions, GetOptions, GetResult, hashComponents, componentsToDebugString } from './agent'
import { Component, UnknownComponents, BuiltinComponents } from './sources'
import { BuiltinComponents } from './sources'
import { Component, UnknownComponents } from './utils/entropy_source'
import { x64hash128 } from './utils/hashing'

// Exports that are under Semantic versioning
export {
Expand All @@ -22,6 +23,9 @@ export default { load, hashComponents, componentsToDebugString }
// The exports below are for private usage. They may change unexpectedly. Use them at your own risk.
/** Not documented, out of Semantic Versioning, usage is at your own risk */
export const murmurX64Hash128 = x64hash128
export { prepareForSources } from './agent'
export { sources } from './sources'
export { getScreenFrame } from './sources/screen_frame'
export {
getFullscreenElement,
isAndroid,
Expand All @@ -32,5 +36,4 @@ export {
isGecko,
isDesktopSafari,
} from './utils/browser'
export { getScreenFrame } from './sources/screen_frame'
export { getComponents, SourcesToComponents } from './sources'
export { loadSources, SourcesToComponents } from './utils/entropy_source'
17 changes: 12 additions & 5 deletions src/sources/audio.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { getBrowserMajorVersion, isMobile, isSafari, isTrident } from '../../tests/utils'
import getAudioFingerprint from './audio'
import getAudioFingerprint, { SpecialFingerprint } from './audio'

describe('Sources', () => {
describe('audio', () => {
it('returns expected value type depending on the browser', async () => {
const result = await getAudioFingerprint()
const result = getAudioFingerprint()

if (isTrident()) {
expect(result).toBe(-2)
expect(result).toBe(SpecialFingerprint.NotSupported)
} else if (isSafari() && isMobile() && (getBrowserMajorVersion() ?? 0) < 12) {
// WebKit has stopped telling its real version in the user-agent string since version 605.1.15,
// therefore the browser version has to be checked instead of the engine version.
expect(result).toBe(-1)
expect(result).toBe(SpecialFingerprint.KnownToSuspend)
} else {
expect(result).toBeGreaterThanOrEqual(0)
// A type guard
if (typeof result !== 'function') {
throw new Error('Expected to be a function')
}
const fingerprint = await result()
expect(fingerprint).toBeGreaterThanOrEqual(0)
const newFingerprint = await result()
expect(newFingerprint).toBe(newFingerprint)
}
})
})
Expand Down
Loading

0 comments on commit 05c801f

Please sign in to comment.