fingerprintjs

Форк
0
259 строк · 9.4 Кб
1
import { isDesktopWebKit, isWebKit, isWebKit606OrNewer } from '../utils/browser'
2
import { isPromise, suppressUnhandledRejectionWarning, wait } from '../utils/async'
3
import { whenDocumentVisible } from '../utils/dom'
4

5
export const enum SpecialFingerprint {
6
  /** The browser is known for always suspending audio context, thus making fingerprinting impossible */
7
  KnownForSuspending = -1,
8
  /** The browser doesn't support audio context */
9
  NotSupported = -2,
10
  /** An unexpected timeout has happened */
11
  Timeout = -3,
12
}
13

14
const sampleRate = 44100
15
const cloneCount = 40000
16
const stabilizationPrecision = 6.2
17

18
/**
19
 * A version of the entropy source with stabilization to make it suitable for static fingerprinting.
20
 * Audio signal is noised in private mode of Safari 17.
21
 */
22
export default async function getAudioFingerprint(): Promise<() => number> {
23
  const finish = await getUnstableAudioFingerprint()
24
  return () => {
25
    const rawFingerprint = finish()
26
    return stabilize(rawFingerprint, stabilizationPrecision)
27
  }
28
}
29

30
/**
31
 * A version of the entropy source without stabilization.
32
 *
33
 * Warning for package users:
34
 * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
35
 */
36
export async function getUnstableAudioFingerprint(): Promise<() => number> {
37
  let fingerprintResult: [true, number] | [false, unknown] | undefined
38

39
  // The timeout is not started until the browser tab becomes visible because some browsers may not want to render
40
  // an audio context in background.
41
  const timeoutPromise = whenDocumentVisible().then(() => wait(500))
42
  const fingerprintPromise = getBaseAudioFingerprint().then(
43
    (result) => (fingerprintResult = [true, result]),
44
    (error) => (fingerprintResult = [false, error]),
45
  )
46
  await Promise.race([timeoutPromise, fingerprintPromise])
47

48
  return () => {
49
    if (!fingerprintResult) {
50
      return SpecialFingerprint.Timeout
51
    }
52
    if (!fingerprintResult[0]) {
53
      throw fingerprintResult[1]
54
    }
55
    return fingerprintResult[1]
56
  }
57
}
58

59
async function getBaseAudioFingerprint(): Promise<number> {
60
  const w = window
61
  const AudioContext = w.OfflineAudioContext || w.webkitOfflineAudioContext
62
  if (!AudioContext) {
63
    return SpecialFingerprint.NotSupported
64
  }
65

66
  // In some browsers, audio context always stays suspended unless the context is started in response to a user action
67
  // (e.g. a click or a tap). It prevents audio fingerprint from being taken at an arbitrary moment of time.
68
  // Such browsers are old and unpopular, so the audio fingerprinting is just skipped in them.
69
  // See a similar case explanation at https://stackoverflow.com/questions/46363048/onaudioprocess-not-called-on-ios11#46534088
70
  if (doesBrowserSuspendAudioContext()) {
71
    return SpecialFingerprint.KnownForSuspending
72
  }
73

74
  const baseSignal = await getBaseSignal(AudioContext)
75
  if (!baseSignal) {
76
    return SpecialFingerprint.Timeout
77
  }
78

79
  // This context copies the last sample of the base signal many times.
80
  // The array of copies helps to cancel the noise.
81
  const context = new AudioContext(1, baseSignal.length - 1 + cloneCount, sampleRate)
82
  const sourceNode = context.createBufferSource()
83
  sourceNode.buffer = baseSignal
84
  sourceNode.loop = true
85
  sourceNode.loopStart = (baseSignal.length - 1) / sampleRate
86
  sourceNode.loopEnd = baseSignal.length / sampleRate
87
  sourceNode.connect(context.destination)
88
  sourceNode.start()
89

90
  const clonedSignal = await renderAudio(context)
91
  if (!clonedSignal) {
92
    return SpecialFingerprint.Timeout
93
  }
94
  const fingerprint = extractFingerprint(baseSignal, clonedSignal.getChannelData(0).subarray(baseSignal.length - 1))
95
  return Math.abs(fingerprint) // The fingerprint is made positive to avoid confusion with the special fingerprints
96
}
97

98
/**
99
 * Checks if the current browser is known for always suspending audio context.
100
 *
101
 * Warning for package users:
102
 * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
103
 */
104
export function doesBrowserSuspendAudioContext() {
105
  // Mobile Safari 11 and older
106
  return isWebKit() && !isDesktopWebKit() && !isWebKit606OrNewer()
107
}
108

109
/**
110
 * Produces an arbitrary audio signal
111
 */
112
async function getBaseSignal(AudioContext: typeof OfflineAudioContext) {
113
  const targetSampleIndex = 3395
114
  const context = new AudioContext(1, targetSampleIndex + 1, sampleRate)
115

116
  const oscillator = context.createOscillator()
117
  oscillator.type = 'square'
118
  oscillator.frequency.value = 1000
119

120
  const compressor = context.createDynamicsCompressor()
121
  compressor.threshold.value = -70
122
  compressor.knee.value = 40
123
  compressor.ratio.value = 12
124
  compressor.attack.value = 0
125
  compressor.release.value = 0.25
126

127
  const filter = context.createBiquadFilter()
128
  filter.type = 'allpass'
129
  filter.frequency.value = 5.239622852977861
130
  filter.Q.value = 0.1
131

132
  oscillator.connect(compressor)
133
  compressor.connect(filter)
134
  filter.connect(context.destination)
135
  oscillator.start(0)
136

137
  return await renderAudio(context)
138
}
139

140
/**
141
 * Renders the given audio context with configured nodes.
142
 * Returns `null` when the rendering runs out of attempts.
143
 *
144
 * Warning for package users:
145
 * This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
146
 */
147
export function renderAudio(context: OfflineAudioContext) {
148
  return new Promise<AudioBuffer | null>((resolve, reject) => {
149
    const retryDelay = 200
150
    let attemptsLeft = 25
151

152
    context.oncomplete = (event) => resolve(event.renderedBuffer)
153

154
    const tryRender = () => {
155
      try {
156
        const renderingPromise = context.startRendering()
157

158
        // `context.startRendering` has two APIs: Promise and callback, we check that it's really a promise just in case
159
        if (isPromise(renderingPromise)) {
160
          // Suppresses all unhandled rejections in case of scheduled redundant retries after successful rendering
161
          suppressUnhandledRejectionWarning(renderingPromise)
162
        }
163

164
        // Sometimes the audio context doesn't start after calling `startRendering` (in addition to the cases where
165
        // audio context doesn't start at all). A known case is starting an audio context when the browser tab is in
166
        // background on iPhone. Retries usually help in this case.
167
        if (context.state === 'suspended') {
168
          // The audio context can reject starting until the tab is in foreground. Long fingerprint duration
169
          // in background isn't a problem, therefore the retry attempts don't count in background. It can lead to
170
          // a situation when a fingerprint takes very long time and finishes successfully. FYI, the audio context
171
          // can be suspended when `document.hidden === false` and start running after a retry.
172
          if (!document.hidden) {
173
            attemptsLeft--
174
          }
175
          if (attemptsLeft > 0) {
176
            setTimeout(tryRender, retryDelay)
177
          } else {
178
            resolve(null)
179
          }
180
        }
181
      } catch (error) {
182
        reject(error)
183
      }
184
    }
185

186
    tryRender()
187
  })
188
}
189

190
function extractFingerprint(baseSignal: AudioBuffer, clonedSample: Float32Array): number {
191
  let fingerprint = undefined
192
  let needsDenoising = false
193

194
  for (let i = 0; i < clonedSample.length; i += Math.floor(clonedSample.length / 10)) {
195
    if (clonedSample[i] === 0) {
196
      // In some cases the signal is 0 on a short range for some reason. Ignoring such samples.
197
    } else if (fingerprint === undefined) {
198
      fingerprint = clonedSample[i]
199
    } else if (fingerprint !== clonedSample[i]) {
200
      needsDenoising = true
201
      break
202
    }
203
  }
204

205
  // The looped buffer source works incorrectly in old Safari versions (>14 desktop, >15 mobile).
206
  // The looped signal contains only 0s. To fix it, the loop start should be `baseSignal.length - 1.00000000001` and
207
  // the loop end should be `baseSignal.length + 0.00000000001` (there can be 10 or 11 0s after the point). But this
208
  // solution breaks the looped signal in other browsers. Instead of checking the browser version, we check that the
209
  // looped signals comprises only 0s, and if it does, we return the last value of the base signal, because old Safari
210
  // versions don't add noise that we want to cancel.
211
  if (fingerprint === undefined) {
212
    fingerprint = baseSignal.getChannelData(0)[baseSignal.length - 1]
213
  } else if (needsDenoising) {
214
    fingerprint = getMiddle(clonedSample)
215
  }
216

217
  return fingerprint
218
}
219

220
/**
221
 * Calculates the middle between the minimum and the maximum array item
222
 */
223
export function getMiddle(signal: ArrayLike<number>): number {
224
  let min = Infinity
225
  let max = -Infinity
226

227
  for (let i = 0; i < signal.length; i++) {
228
    const value = signal[i]
229
    // In very rare cases the signal is 0 on a short range for some reason. Ignoring such samples.
230
    if (value === 0) {
231
      continue
232
    }
233
    if (value < min) {
234
      min = value
235
    }
236
    if (value > max) {
237
      max = value
238
    }
239
  }
240

241
  return (min + max) / 2
242
}
243

244
/**
245
 * Truncates some digits of the number to make it stable.
246
 * `precision` is the number of significant digits to keep. The number may be not integer:
247
 *  - If it ends with `.2`, the last digit is rounded to the nearest multiple of 5;
248
 *  - If it ends with `.5`, the last digit is rounded to the nearest even number;
249
 */
250
export function stabilize(value: number, precision: number): number {
251
  if (value === 0) {
252
    return value
253
  }
254

255
  const power = Math.floor(Math.log10(Math.abs(value)))
256
  const precisionPower = power - Math.floor(precision) + 1
257
  const precisionBase = 10 ** -precisionPower * ((precision * 10) % 10 || 1)
258
  return Math.round(value * precisionBase) / precisionBase
259
}
260

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.