Evercookie представляет разработчику идентифицировать пользователя, если тот удалил куки. Основная цель evercookie — это записать во все доступные хранилища браузера уникальный идентификатор. Если идентификатор удаляется из какого либо хранилища, то значение восстанавливается с помощью чтения значения из другого хранилища.

Реализацию evercookie можно глянуть в репозитории на github — https://github.com/samyk/evercookie.

Принцип работы evercookie по шагам:

  1. Получение уникального идентификатора (UID) из доступных хранилищ.

  2. Если при получении идентификаторов были получены разные значения,то тогда выбирается лучшее значение из доступных (максимум из найденных значений).

  3. Если UID не найден, то тогда пользователю нужно присвоить уникальный идентификатор. Так как кэширование в некоторых механизмах подразумевает кодирование значения, то для полноты работы желательно использовать числовой идентификатор, который проще закодировать.

Механизмы хранения данных

Решение построено на семнадцати механизмах хранения.

  1. Стандартные cookie‑файлы HTTP

  2. HTTP Strict Transport Security (HSTS)

  3. Локальные общие объекты (Flash cookies)

  4. Изолированное хранилище Silverlight

  5. Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).

  6. Сохранение cookie‑файлов в истории поиска

  7. Хранение cookie‑файлов в HTTP ETag

  8. Сохранение cookie‑файлов в веб‑кэше

  9. Кэширование window.name

  10. Хранилище пользовательских данных Internet Explorer

  11. Веб‑хранилище сеансов HTML5

  12. Локальное веб‑хранилище HTML5

  13. Глобальное хранилище HTML5

  14. База данных HTML5 Web SQL через SQLite

  15. HTML5 IndexedDB

  16. Java JNLP PersistenceService

  17. Эксплойт Java CVE-2013–0422

Список взят с Википедии и сравнен с приведенными механизмами.

Под вопросом пункт - сохранение cookie‑файлов в веб‑кэше. Если речь идет про basic authorization, то тогда все хорошо.

Краткое описание используемых механизмов с объяснением применения механизма кэширования.

Стандартные cookie-файлы HTTP

HTTP-куки — это классические куки браузера. Большинство сайтов используют куки для сессий клиентов. Пользователь посещает сайт и ему сервер устанавливает в куки UID, по которому в дальнейшем сервер будет понимать, что за клиент запрашивает данные.

HTTP Strict Transport Security (HSTS)

HTTP Strict Transport Security — это специальный механизм для определения использования защищенного соединения. Результат использования хранится в браузере и связан с конкретным доменом и поддоменами. Механизм использует набор доменов, каждый их которых может хранить «да» или «нет». Формально один домен это один бит, если взять 32 домена, то этого достаточно, чтобы закодировать int.

Наример, если пользователю присвоить значение равное 21, то в двоичной системе это будет: 00 000 000 000 000 000 000 000 000 010 101.

Теперь на каждый из доменов отправляется значение 0 или 1, где сервер себе сохраняет работает ли HSTS для данного домена. После удаления данных из хранилищ, можно запросить у каждого из доменов нужно ли использовать https. И преобразовав обратно в число из двоичной системы можно получить стертый идентификатор.

Локальные общие объекты (Flash cookies)

Adobe Flash - одна из технологий создания анимаций в браузере. Флеш можно запустить на современном браузере, но из‑за своей ограниченности этого уже никто не делает. Механизм восстановления заключается в использовании кеша flash. Наверное, это единственное, трудно удаляемое значение, так как его сложно сбросить в браузере.

Стоит проверить работоспособность решения. Возможно этот механизм работает до сих пор.

Я отказался от хранения данных во флеше, так как все современные браузеры будут блокировать его. А обходные решения для запуска выглядят сомнительными.

Изолированное хранилище Silverlight

Silverlight — это аналог flash от Microsoft. Механизм восстановления аналогичен флешу, где просто используется системный кеш.

Решение может работать до сих пор, однако количество пользователей с работающим silverlight стремится к нулю.

Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).

Механизм построен на генерации изображения и его кешировании. При запросе изображения, идет обратное декодирование и таким образом можно восстановить стертый UID.

Сохранение cookie-файлов в истории поиска

Метод построенный на определении посещенных сайтов. Раньше браузер позволял понять, посещен ли сайт ранее с помощью псевдокласса :visited в CSS. В настоящий момент, Javascript не имеет доступа к данному псевдоклассу элемента ссылки.

Хранение cookie-файлов в HTTP ETag

Etag — это заголовок, который указывает версию ресурса. Механизм заключается в присваивании UID версии etag.

Сохранение cookie‑файлов в веб‑кэше (Basic Authentication cache)

Раньше при базовой аутентификации можно было записать кеш. Механизм строился на записи изображения на сервер. Для восстановления была попытка загрузки изображения с сервера, где требовалась авторизация.

Я лично сам не сталкивался с подобным и в поздних коммитах эту функциональность выключили. 

Кэширование window.name

У глобального объекта Window есть записываемое свойство — name, которое задает имя окна. Обычно с помощью этого свойства передавали данные между окнами.

Особенность записи в window.name - это возможность сохранять значение даже при очистке хранилищ.

Например, если в браузере открыть панель разработчика и перейти во вкладку данных и очистить их и не закрывать текущую страницу, то имя окна не будет сброшено, а значит evercookie будет восстановлена и записана снова везде.

Хранилище пользовательских данных Internet Explorer

Механизм записи данных в IE. Возможно это работает до сих пор, но использование IE уменьшается, и скоро совсем исчезнет.

Метод построен на создании div и записи UID. При восстановлении можно прочитать установленное значение из кеша.

Так как это работает только в IE, остается надеяться, что все так и есть. Я не углублялся в данную технику. 

Веб-хранилище сеансов HTML5

SessionStorage — хранилище для данных сессии. Автоматически очищается после завершении сессии.

Механизм построен просто на записи ключа в хранилище и его чтении.

Работает как часы.

Локальное веб-хранилище HTML5

LocalStorage — это хранилище для локальных данных. Данные хранятся пока не будут очищены вручную. Механизм построен на записи ключа в хранилище и его чтении.

Глобальное хранилище HTML5

Кроссдоменное хранилище, которое раньше было доступно для записи и чтения. В настоящий момент не доступно.

Механизм работы аналогичен LocalStorage и SessionStorage.

База данных HTML5 Web SQL через SQLite

Web SQL  — локальная база SQLite данных доступная в браузере.

Механизм работы заключается в создании новой таблицы и записи туда UID. Так как стандарт больше не развивается, то скоро браузеры прекратят его поддержку.

HTML5 IndexedDB

IndexedDB — это замена Web SQL.

Механизм работы такой же как и при Web SQL. Сначала создается таблица, а затем записывается UID.

Java JNLP PersistenceService

PersistenceService — это сервис, который позволяет сохранять данные в кеше с помощью java.

Механизм использует технологию Java Applet, которая умерла вместе с Flash и Silverlight.

Она часто требует установки дополнительных плагинов, ждет запуска виртуальной машины. Использование в современном вебе сомнительно.

Эксплойт Java CVE-2013-0422

Эксплойт, который позволяет выполнять любой код из‑за неизвестной ошибки. Применимо к старым версиям Java.

В итоге получается актуальный на 2023 год список работающих механизмов:

+/-

Метод

+

Стандартные cookie-файлы HTTP 

+

HSTS

-

Локальные общие объекты*

-

Изолированное хранилище Silverlight*

+

Хранение cookie-файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных 

-

Сохранение cookie-файлов в истории поиска

+

Хранение cookie-файлов в HTTP ETag

-

Сохранение cookie-файлов в веб-кэше (Basic Authentication cache)

+

Кэширование window.name

-

Хранилище пользовательских данных Internet Explorer*

+

Веб-хранилище сеансов HTML5

+

Локальное веб-хранилище HTML5

-

Глобальное хранилище HTML5

+

База данных HTML5 Web SQL через SQLite

+

HTML5 IndexedDB

-

Java JNLP PersistenceService*

-

Эксплойт Java CVE-2013-0422*

Все механизмы помеченные звездочкой скорее не работают, чем работают. 

Обычно это очень старые браузеры, причем настольные.

Единственное под вопросом HSTS. Потенциально, все должно работать, но стоит это проверить. 

Работает ли это все в 2023 году

Если говорить о доступных средствах, то большая часть старых уязвимостей была исправлена в современных браузерах.

Если вы хотите создать новую сессию, достаточно открыть новую вкладку в инкогнито. Текущая реализация evercookie не сможет восстановить значение.

Для чего тогда стоит использовать evercookie?

Evercookie хорошо работает для обычных пользователей, которые не чистят историю, не удаляют куки. Это нужно чтобы связать конкретные устройства с пользователем. Для этих целей эверкука подходит идеально.

Другими словами это не работает как механизм вечных кук?

Есть другие инструменты, для определения пользователей - фингерпринтинг. Одна из техник это определение установленных шрифтов в браузере.

Однако, стоит учитывать, что все фингерпринты не уникальны. Если взять два новых iphone, одной серии и модели и запустить на них фингерпринты, то они совпадут с 99.99% вероятностью.

Альтернативные способы

У читателя может появиться вопрос, а появились ли какие-то новые способы кэширования?

Из последних - это supercookie построенная на фавиконах. Проект можно посмотреть тут - https://github.com/jonasstrehle/supercookie.

Утверждается, что это работает на chrome 111.

Подробнее можно прочитать в статье ITSumma - Эффективный фингерпринтинг через кэш фавиконов в браузере.

Также есть issue в проекте эверкуки https://github.com/samyk/evercookie/issues/136, в которой утверждается что можно использовать кэш TLS сессии.

Мне неизвестно работает это или нет. Нужно исследовать.

Зачем использовать Evercookie

Как говорилось раньше, эверкука позволяет восстанавливать сессию пользователя даже после очистки им данных и удаления кук.

В основном это необходимо для создания антифрод системы. Обычный пользователь не будет удалять свои данные, а вот мошенник только и пытается замести следы.

Если клиент будет регистрироваться на сайте с одной и той же evercookie, то в дальнейшем можно будет связать все эти регистрации в единое целое и что‑то с этим сделать.

Структура Evercookie

Структура проекта в репозитории:

├── assets
│   ├── EvercookieCacheServlet.java
│   ├── EvercookieEtagServlet.java
│   ├── evercookie.fla
│   ├── evercookie.jar
│   ├── evercookie.jnlp
│   ├── EvercookiePngServlet.java
│   ├── evercookie_sl
│   │   ├── evercookie
│   │   │   ├── App.xaml
│   │   │   ├── App.xaml.cs
│   │   │   ├── Bin
│   │   │   │   └── Debug
│   │   │   │       ├── AppManifest.xaml
│   │   │   │       ├── evercookie.dll
│   │   │   │       ├── evercookie.pdb
│   │   │   │       ├── evercookieTestPage.html
│   │   │   │       └── evercookie.xap
│   │   │   ├── evercookie.csproj
│   │   │   ├── evercookie.csproj.user
│   │   │   ├── MainPage.xaml
│   │   │   ├── MainPage.xaml.cs
│   │   │   ├── obj
│   │   │   │   └── Debug
│   │   │   │       ├── App.g.cs
│   │   │   │       ├── App.g.i.cs
│   │   │   │       ├── DesignTimeResolveAssemblyReferences.cache
│   │   │   │       ├── DesignTimeResolveAssemblyReferencesInput.cache
│   │   │   │       ├── evercookie.csproj.FileListAbsolute.txt
│   │   │   │       ├── evercookie.dll
│   │   │   │       ├── evercookie.g.resources
│   │   │   │       ├── evercookie.pdb
│   │   │   │       ├── MainPage.g.cs
│   │   │   │       ├── MainPage.g.i.cs
│   │   │   │       └── XapCacheFile.xml
│   │   │   └── Properties
│   │   │       ├── AppManifest.xml
│   │   │       └── AssemblyInfo.cs
│   │   ├── evercookie.sln
│   │   └── evercookie.suo
│   ├── evercookie.swf
│   └── evercookie.xap
├── ChangeLog
├── css
│   └── master.css
├── index.html
├── js
│   ├── evercookie.js
│   └── swfobject-2.2.min.js
├── php
│   ├── _cookie_name.php
│   ├── evercookie_cache.php
│   ├── evercookie_etag.php
│   ├── evercookie_png.php
│   └── hsts_cookie.php
└── README.md

Так как Java, Flash и Silverlight выбыли, интересными остаются только следующие файлы:

├── js
│   └──  evercookie.js
└──  php
     ├── _cookie_name.php
     ├── evercookie_cache.php
     ├── evercookie_etag.php
     ├── evercookie_png.php
     └── hsts_cookie.php

Реализация Evercookie

Исходный код evercookie выглядит следующим образом:

 this.evercookie_cache = function (name, value) {
      if (value !== undefined) {
        // make sure we have evercookie session defined first
        document.cookie = opts.cacheCookieName + "=" + value + "; path=/; domain=" + _ec_domain;
        // {{ajax request to opts.cachePath}} handles caching
        self.ajax({
          url: _ec_baseurl + _ec_phpuri + opts.cachePath + "?name=" + name + "&cookie=" + opts.cacheCookieName,
          success: function (data) {}
        });
      } else {
        // interestingly enough, we want to erase our evercookie
        // http cookie so the php will force a cached response
        var origvalue = this.getFromStr(opts.cacheCookieName, document.cookie);
        self._ec.cacheData = undefined;
        document.cookie = opts.cacheCookieName + "=; expires=Mon, 20 Sep 2010 00:00:00 UTC; path=/; domain=" + _ec_domain;

        self.ajax({
          url: _ec_baseurl + _ec_phpuri + opts.cachePath + "?name=" + name + "&cookie=" + opts.cacheCookieName,
          success: function (data) {
            // put our cookie back
            document.cookie = opts.cacheCookieName + "=" + origvalue + "; expires=Tue, 31 Dec 2030 00:00:00 UTC; path=/; domain=" + _ec_domain;

            self._ec.cacheData = data;
          }
        });
      }
    };

Полный код тут: https://github.com/samyk/evercookie/blob/master/js/evercookie.js

Код немного запутан, когда видишь его в первый раз, но потом приходит его понимание.

Я долго обходил стороной рефакторинг evercookie, так как она работала. Но все больше ее частей становится неактуальной, начали появляться проблемы с CORS. Так как один из моих Angular проектов активно использует evercookie, то я решил переписать evercookie на новый стек.

Сначала определим интерфейс, который будет хранить параметры evercookie:

/**
 * Evercookie options
 */
export interface EvercookieOptions {
  /**
   * Cookie name for HTTP cookie
   *
   * default: `evercookie_cache`
   */
  cacheCookieName: string;

  /**
   * Path to script cache
   *
   * default: `/cache.php`
   */
  cachePath: string;

  /**
   * Cookie name for PNG
   *
   * default: `evercookie_png`
   */
  pngCookieName: string;

  /**
   * Path to script png
   *
   * default: `/png.php`
   */
  pngPath: string;

  /**
   * Cookie name for ETag
   *
   * default: `evercookie_etag`
   */
  etagCookieName: string;

  /**
   * Path to script etag
   *
   * default: `/etag.php`
   */
  etagPath: string;

  /**
   * Path to backend
   *
   * example: `https://your-evercookie-server.com`
   */
  baseurl: string;

  /**
   * Domain from window.location.host
   *
   * example: `.your-site-with-evercookie.com`
   */
  domain: string;

  /**
   * Path to PHP folder in backend
   *
   * default: `/php`
   */
  phpuri: string;

  /**
   * Name evercookie in storages
   *
   * default: `uid`
   */
  name: string;

  /**
   * Past date
   *
   * default: `'Thu, 01 Jun 2000 00:00:00 GMT'`
   */
  expiresPast: string;

  /**
   * Expires date
   *
   * default: `'Thu, 01 Jun 2000 00:00:00 GMT'`
   */
  expires: string;
}
  • cacheCookieName - имя Cache куки;

  • cachePath - путь до скрипта с Cache;

  • pngCookieName - имя PNG куки;

  • pngPath - путь до скрипта с PNG;

  • etagCookieName - имя ETag куки;

  • etagPath - путь до скрипта ETag;

  • phpuri - путь до папки со скриптами PHP;

  • baseurl - путь до бекенда, где выложен проект evercookie;

  • domain - домен, на который будут ставиться куки. Домен по-умолчанию берется как '.' + window.location.host;

  • name - имя ключа, который будет использован при записи в хранилище;

  • expiresPast - прошедшая дата, для сброса куки;

  • expires - дата, до которой будет действовать кука.

Создадим сервис, который будет читать и записывать evercookie с дефолтными опциями:

export class EvercookieService {
  private readonly options: EvercookieOptions = {
    cacheCookieName: 'evercookie_cache',
    pngCookieName: 'evercookie_png',
    etagCookieName: 'evercookie_etag',
    etagPath: '/etag.php',
    baseurl: 'http://localhost:8080/evercookie',
    domain: '.',
    phpuri: '/php',
    pngPath: '/png.php',
    cachePath: '/cache.php',
    name: 'uid',
    expiresPast: 'Thu, 01 Jun 2000 00:00:00 GMT',
    expires: 'Sat, 01 Jun 2030 00:00:00 GMT',
  };

  readonly isBrowser!: boolean;

  constructor(
    private readonly destroyRef: DestroyRef,
    private readonly httpClient: HttpClient,
    @Inject(DOCUMENT) private readonly document: Document,
    // eslint-disable-next-line @typescript-eslint/ban-types
    @Inject(PLATFORM_ID) private readonly platformId: Object
  ) {
    this.isBrowser = isPlatformBrowser(this.platformId);

    if (this.isBrowser && this.window) {
      this.options.domain = `.${this.window?.location.host.replace(/:\d+/, '')}`;
    }
  }

  get window() {
    return this.document.defaultView;
  }
}

Для создания запросов используем стандартный fetch, но с выключенными CORS:

fetch(url, {
  method: 'GET',
  mode: 'no-cors',
  headers: {
    Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
  },
});

Так как Angular построен на Observable, то метод примет вид:

  private request(url: string): Observable<string> {
    return from(
      fetch(url, {
        method: 'GET',
        mode: 'no-cors',
        headers: {
          Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
        },
      })
    ).pipe(
      switchMap((response) => from(response.text())),
      map((result) => result ?? undefined),
      takeUntilDestroyed(this.destroyRef)
    );
  }

Для парсинга ответов и значений понадобятся две утилиты, которые просто взяты из реализации evercookie:

export function replace(str: string, key: string, value: string | number): string {
  if (str.indexOf('&' + key + '=') > -1 || str.indexOf(key + '=') === 0) {
    // find start
    let idx = str.indexOf('&' + key + '=');
    if (idx === -1) {
      idx = str.indexOf(key + '=');
    }

    // find end
    const end = str.indexOf('&', idx + 1);

    return end !== -1 ? `${str.substr(0, idx)}${str.substr(end + (idx ? 0 : 1))}&${key}=${value}` : `${str.substr(0, idx)}&${key}=${value}`;
  }

  return `${str}&${key}=${value}`;
}

export function getFromStr(name: string, text: unknown) {
  if (typeof text !== 'string') {
    return;
  }

  const nameEQ = name + '=';
  const ca = text.split(/[;&]/);
  let c;

  for (let i = 0; i < ca.length; i++) {
    c = ca[i];

    while (c.charAt(0) === ' ') {
      c = c.substring(1, c.length);
    }

    if (c.indexOf(nameEQ) === 0) {
      return c.substring(nameEQ.length, c.length);
    }
  }

  return;
}

Добавим типы для Window:

declare global {
  interface Window {
    readonly mozIndexedDB?: IDBFactory;
    readonly webkitIndexedDB?: IDBFactory;
    readonly msIndexedDB?: IDBFactory;
    readonly openDatabase?: (
      name: string,
      version: string,
      displayName: string,
      estimatedSize: number
    ) => {
      transaction(
        callback: (arg: {
          executeSql: (sql: string, params?: any[], cb?: (tx: any, cb: any) => void, eh?: (err: any) => void) => void;
        }) => void
      ): void;
    };
  }
}
  • mozIndexedDB, webkitIndexedDB, msIndexedDB - платформенные реализации IndexedDB;

  • openDatabase - метод доступа к Web SQL.

Реализация кэширование window.name

Для установки значения достатоно записать в window.name нужное значение.

private setWindowName(value: string | number): void {
  window.name = replace(window.name, this.options.name, value);
}

Для чтения просто взять значение window.name.

private getWindowName(): number | undefined {
  const value = getFromStr(this.options.name, window.name);
  
  return typeof value !== 'undefined' ? Number(value) : undefined;
}

Angular:

  private getWindowName(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        const eid = getFromStr(this.options.name, this.window?.name);
        observer.next(typeof eid !== 'undefined' ? Number(eid) : undefined);
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

  private setWindowName(value: string | number): void {
    try {
      if (this.window) {
        this.window.name = replace(this.window.name, this.options.name, value);
      }
    } catch (e) {
      /* empty */
    }
  }
  • Так как не все методы чтения выполняются синхронно, то можно все методы оборачиваются в Observable.

  • Для корректного завершения потоков, все операции выполняются в try‑catch.

Стандартные cookie‑файлы HTTP

Установка куки производится просто записью в document.cookie.

  private setCache(value: string | number): void {
    document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

    void fetch(`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`);
  }

Чтение куки:

  private async getCache(): Promise<number | undefined> {
    const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
    // reset cookie
    this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

    return fetch(
      `${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
    ).then((response) => {
      this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

      return response.text();
    }).then(result => result !== '' ? Number(result) : undefined);
  }
  1. Сначала стирается предыдущее значение куки.

  2. Затем выполняется запрос куки.

  3. После получения ответа сервера, восстанавливается предыдущее значение и метод возвращает ответ от сервера.

Зачем нужно восстанавливать предыдущее значение я не понял. Возможно когда все методы отработают, есть шанс что данное значение было лучшим и его нужно будет восстановить. Если сервер вернет undefined и кука будет перезатерта, то после рефреша не получиться ничего восстановить.

Angular реализация:

 private getCache(): Observable<number | undefined> {
    return new Observable((observer) => {
      // interestingly enough, we want to erase our evercookie
      // http cookie so the php will force a cached response
      const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
      this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

      this.request(
        `${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
      )
        .pipe(
          tap((response) => {
            this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

            observer.next(response !== '' ? Number(response) : undefined);
            observer.complete();
          }),
          catchError(() => {
            observer.next(undefined);
            observer.complete();

            return EMPTY;
          })
        )
        .subscribe();
    });
  }

  private setCache(value: string | number): void {
    // eslint-disable-next-line max-len
    this.document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

    this.request(
      // eslint-disable-next-line max-len
      `${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
    )
      .pipe(catchError(() => EMPTY))
      .subscribe();
  }

Реализация серверной части на PHP:

<?php
/* evercookie, by samy kamkar, 09/20/2010
 *  http://samy.pl : code@samy.pl
 *
 * This is the server-side simple caching mechanism.
 *
 * -samy kamkar
 */

// we get cookie name from current file name so remember about it when rename of this file will be required 
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);

// we don't have a cookie, user probably deleted it, force cache
if (empty($_COOKIE[$cookie_name])) {
    header('HTTP/1.1 304 Not Modified');
    exit;
}

header('Content-Type: text/html');
header('Last-Modified: Wed, 30 Jun 2010 21:36:48 GMT');
header('Expires: Tue, 31 Dec 2030 23:30:45 GMT');
header('Cache-Control: private, max-age=630720000');

echo $_COOKIE[$cookie_name];

Как видно из реализации, то просто читается кука и записывается в ответ с необходимыми заголовками.

Хранение cookie‑файлов, закодированных в значениях RGB

Установка значения:

private setPng(value: string | number): void {
  try {
    this.document.cookie = this.options.pngCookieName + '=' + value + '; path=/; domain=' + this.options.domain;

    const img = new Image();
    img.style.visibility = 'hidden';
    img.style.position = 'absolute';
    img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
    img.crossOrigin = 'Anonymous';
  } catch (e) {
    /* empty */
  }
}

Суть метода в том, чтобы запросить необходимое изображение, которое будет в дальнейшем закешировано.

Механизм получения куки построен на создании карты с закодированными цветами. Перебирая каждый пиксель можно получить закодированный UID.

По шагам:

  1. Создается новый canvas.

  2. Сбрасывается кука.

  3. Создается новый контекст.

  4. Отрисовывается изображение.

  5. Декодируем каждый пиксель.

private getPng(): Observable<number | undefined> {
  return new Observable((observer) => {
    const canvas = this.document.createElement('canvas');
    canvas.style.visibility = 'hidden';
    canvas.style.position = 'absolute';
    canvas.width = 200;
    canvas.height = 1;

    if (canvas && canvas.getContext) {
      const img = new Image();
      img.style.visibility = 'hidden';
      img.style.position = 'absolute';
 
      const ctx = canvas.getContext('2d');
      const origvalue = getFromStr(this.options.pngCookieName, this.document.cookie);

      this.document.cookie = `${this.options.pngCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

      img.onload = () => {
        this.document.cookie = `${this.options.pngCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

        let png = '';

        if (ctx) {
          ctx.drawImage(img, 0, 0);

          // get CanvasPixelArray from  given coordinates and dimensions
          const pix = ctx.getImageData(0, 0, 200, 1).data;

          // loop over each pixel to get the "RGB" values (ignore alpha)
          for (let i = 0, n = pix.length; i < n; i += 4) {
            if (pix[i] === 0) {
              break;
            }
            png += String.fromCharCode(pix[i]);
            if (pix[i + 1] === 0) {
              break;
            }
            png += String.fromCharCode(pix[i + 1]);
            if (pix[i + 2] === 0) {
              break;
            }
            png += String.fromCharCode(pix[i + 2]);
          }
        }

        observer.next(png !== '' ? Number(png) : undefined);
        observer.complete();
      };

      img.onerror = () => {
        observer.next(undefined);
        observer.complete();
      };
      img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
      img.crossOrigin = 'Anonymous';
    } else {
      observer.next(undefined);
      observer.complete();
    }
  });
}

Реализация на бекенде:

<?php
/* evercookie, by samy kamkar, 09/20/2010
 *  http://samy.pl : code@samy.pl
 *
 * This is the server-side variable PNG generator for evercookie.
 * If an HTTP cookie is passed, the cookie data gets converted into
 * RGB-values in a PNG image. The PNG image is printed out with a
 * 20-year cache expiration date.
 *
 * If for any reason this file is accessed again WITHOUT the cookie,
 * as in the user deleted their cookie, the code returns back with
 * a forced 'Not Modified' meaning the browser should look at its
 * cache for the image.
 *
 * The client-side code then places the cached image in a canvas and
 * reads it in pixel by pixel, converting the PNG back into a cookie.
 *
 * -samy kamkar
 */

// we get cookie name from current file name so remember about it when rename of this file will be required
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);

// we don't have a cookie, user probably deleted it, force cache
if (empty($_COOKIE[$cookie_name])) {
    if(!headers_sent()) {
        header('HTTP/1.1 304 Not Modified');
    }
    exit;
}

// width of 200 means 600 bytes (3 RGB bytes per pixel)
$x = 200;
$y = 1;

$gd = imagecreatetruecolor($x, $y);

$data_arr = str_split($_COOKIE[$cookie_name]);

$x = 0;
$y = 0;
for ($i = 0, $i_count = count($data_arr); $i < $i_count; $i += 3) {
    $red   = isset($data_arr[$i])   ? ord($data_arr[$i])   : 0;
    $green = isset($data_arr[$i+1]) ? ord($data_arr[$i+1]) : 0;
    $blue  = isset($data_arr[$i+2]) ? ord($data_arr[$i+2]) : 0;
    $color = imagecolorallocate($gd, $red, $green, $blue);
    imagesetpixel($gd, $x++, $y, $color);
}

if(!headers_sent()) {
    header('Content-Type: image/png');
    header('Last-Modified: Wed, 30 Jun 2010 21:36:48 GMT');
    header('Expires: Tue, 31 Dec 2030 23:30:45 GMT');
    header('Cache-Control: private, max-age=630720000');
}

// boom. headshot.
imagepng($gd);

Хранение cookie‑файлов в HTTP ETag

Сохранение ETag аналогично записи куки.

  private setEtag(value: string | number): void {
    try {
      this.document.cookie = `${this.options.etagCookieName}=${value}; path=/; domain=${this.options.domain}`;

      this.request(
        `${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
      )
        .pipe(catchError(() => EMPTY))
        .subscribe();
    } catch (e) {
      /* empty */
    }
  }

Получение ETag аналогично получению куки. Разница будет только в серверной реализации.

  private getEtag(): Observable<number | undefined> {
    return new Observable((observer) => {
      const origvalue = getFromStr(this.options.etagCookieName, this.document.cookie);
      this.document.cookie = `${this.options.etagCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

      this.request(
        `${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
      )
        .pipe(
          tap((response) => {
            this.document.cookie = `${this.options.etagCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

            observer.next(response !== '' ? Number(response) : undefined);
            observer.complete();
          }),
          catchError(() => {
            observer.next(undefined);
            observer.complete();

            return EMPTY;
          })
        )
        .subscribe();
    });
  }

Реализация на PHP:

<?php
/* evercookie, by samy kamkar, 09/20/2010
 *  http://samy.pl : code@samy.pl
 *
 * This is the server-side ETag software which tags a user by
 * using the Etag HTTP header, as well as If-None-Match to check
 * if the user has been tagged before.
 *
 * -samy kamkar
 */

// we get cookie name from current file name so remember about it when rename of this file will be required
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);

// we don't have a cookie, so we're not setting it
if (empty($_COOKIE[$cookie_name])) {
    // read our etag and pass back
    if (!function_exists('apache_request_headers')) {
        function apache_request_headers() {
            // Source: http://www.php.net/manual/en/function.apache-request-headers.php#70810
            $arh = array();
            $rx_http = '/\AHTTP_/';
            foreach ($_SERVER as $key => $val) {
                if (preg_match($rx_http, $key)) {
                    $arh_key = preg_replace($rx_http, '', $key);
                    $rx_matches = array();
                    // do some nasty string manipulations to restore the original letter case
                    // this should work in most cases
                    $rx_matches = explode('_', $arh_key);
                    if (count($rx_matches) > 0 and strlen($arh_key) > 2) {
                        foreach ($rx_matches as $ak_key => $ak_val) {
                            $rx_matches[$ak_key] = ucfirst(strtolower($ak_val));
                        }
                        $arh_key = implode('-', $rx_matches);
                    }
                    $arh[$arh_key] = $val;
                }
            }
            return ($arh);
        }
    }

    // Headers might have different letter case depending on the web server.
    // So, change all headers to uppercase and compare it.
    $headers = array_change_key_case(apache_request_headers(), CASE_UPPER);
    if(isset($headers['IF-NONE-MATCH'])) {
        // extracting value from ETag presented format (which may be prepended by Weak validator modifier)
        $etag_value = preg_replace('|^(W/)?"(.+)"$|', '$2', $headers['IF-NONE-MATCH']);
        header('HTTP/1.1 304 Not Modified');
        header('ETag: "' . $etag_value . '"');
        echo $etag_value;
    }
    exit;
}

// set our etag
header('ETag: "' . $_COOKIE[$cookie_name] . '"');
echo $_COOKIE[$cookie_name];

Как видно из примера, сначала проверяется наличие куки. Если куки нет,то идет установка, в противном случае просто ставится заголовок и возвращается кука.

Веб‑хранилище сеансов HTML5

Уставка значения в sessionStorage:

 private setSessionStorage(value: string | number) {
    try {
      if (value !== undefined && this.window?.sessionStorage) {
        this.window?.sessionStorage.setItem(this.options.name, value.toString());
      }
    } catch (e) {
      /* empty */
    }
  }

Обертка try‑catch здесь обязательна, так как некоторые браузеры в режиме инкогнито могут не предоставлять доступ к хранилищу.

Получение данных из sessionStorage:

  private getSessionStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (this.window?.sessionStorage) {
          const value = this.window.sessionStorage.getItem(this.options.name);
          observer.next(value ? Number(value) : undefined);
        } else {
          observer.next(undefined);
        }
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

Локальное веб‑хранилище HTML5

Чтение и запись в LocalStorage ничем не отличается от записи и чтения в SessionStorage.

  private setLocalStorage(value: string | number) {
    try {
      if (value !== undefined && this.window?.localStorage) {
        this.window.localStorage.setItem(this.options.name, value.toString());
      }
    } catch (e) {
      /* empty */
    }
  }

  private getLocalStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (this.window?.localStorage) {
          const value = this.window.localStorage.getItem(this.options.name);

          observer.next(value ? Number(value) : undefined);
        } else {
          observer.next(undefined);
        }
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

База данных HTML5 Web SQL через SQLite

Запись в Web SQL:

 private setDatabaseStorage(value: string | number) {
    try {
      if (typeof this.window?.openDatabase === 'function') {
        const database = this.window.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);

        database.transaction((tx) => {
          tx.executeSql(
            'CREATE TABLE IF NOT EXISTS cache(' +
              'id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ' +
              'name TEXT NOT NULL, ' +
              'value TEXT NOT NULL, ' +
              'UNIQUE (name)' +
              ')',
            []
          );
          tx.executeSql('INSERT OR REPLACE INTO cache(name, value) ' + 'VALUES(?, ?)', [this.options.name, value]);
        });
      }
    } catch (e) {
      /* empty */
    }
  }

Сначала открывается требуемая база данных — sqlite_evercookie.

Затем создается транзакция, в которой создается таблица cache, если ее не существует. В конце идет запись в таблицу требуемого значения.

Получение данных из Web SQL. Алгоритм следующий:

  • Сначала открываем базу данных.

  • Затем читаем значение из таблицы.

  private getDatabaseStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (typeof this.window?.openDatabase === 'function') {
          const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);

          database.transaction((tx) => {
            tx.executeSql(
              'SELECT value FROM cache WHERE name=?',
              [this.options.name],
              (tx1, result1) => {
                observer.next(result1.rows.length >= 1 ? result1.rows.item(0).value : undefined);
                observer.complete();
              },
              () => {
                observer.next(undefined);
                observer.complete();
              }
            );
          });
        } else {
          observer.next(undefined);
          observer.complete();
        }
      } catch (e) {
        observer.next(undefined);
        observer.complete();
        /* empty */
      }
    });
  }

HTML5 IndexedDB

Запись в IndexedDB.

  • Сначала открывается нужная база.

  • Затем создается транзакция на чтение и запись.

  • После этого идет запись в таблицу.

  private setIndexedDb(value: string | number) {
    try {
      const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;

      if (indexedDB) {
        const request = indexedDB.open('idb_evercookie', 1);

        request.onupgradeneeded = (event) => {
          const db = request.result || (event as any).target.result;

          db.createObjectStore('evercookie', {
            keyPath: 'name',
          });
        };

        request.onsuccess = (event) => {
          const idb = request.result || (event as any).target.result;

          if (idb.objectStoreNames.contains('evercookie')) {
            try {
              const tx = idb.transaction(['evercookie'], 'readwrite');
              const objst = tx.objectStore('evercookie');
              objst.put({ name: this.options.name, value: value });
            } catch (e) {
              /* empty */
            }
          }
          idb.close();
        };
      }
    } catch (e) {
      /* empty */
    }
  }

Получение данных аналогично:

  private getIndexedDb(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;

        if (indexedDB) {
          const request = indexedDB.open('idb_evercookie', 1);

          request.onupgradeneeded = (event) => {
            const db = request.result || (event as any).target.result;

            db.createObjectStore('evercookie', {
              keyPath: 'name',
            });
          };

          request.onsuccess = (event) => {
            const idb = request.result || (event as any).target.result;

            if (!idb.objectStoreNames.contains('evercookie')) {
              observer.next(undefined);
              observer.complete();
            } else {
              const tx = idb.transaction(['evercookie']);
              const objst = tx.objectStore('evercookie');
              const qr = objst.get(this.options.name);

              qr.onsuccess = () => {
                observer.next(qr.result !== undefined ? qr.result.value : undefined);
                observer.complete();
              };

              qr.onerror = () => {
                observer.next(undefined);
                observer.complete();
              };
            }
            idb.close();
          };

          request.onerror = () => {
            observer.next(undefined);
            observer.complete();
          };
        }
      } catch (e) {
        observer.next(undefined);
        observer.complete();
      }
    });
  }

Теперь, если собрать все методы в месте, то установка:

  set(eid: number | undefined): void {
    if (eid) {
      this.setEtag(eid);
      this.setPng(eid);
      this.setCache(eid);
      this.setIndexedDb(eid);
      this.setLocalStorage(eid);
      this.setSessionStorage(eid);
      this.setDatabaseStorage(eid);
      this.setWindowName(eid);
    }
  }

Метод получения всех кук:

  get(): Observable<EvercookieResponse> {
    return combineLatest([
      this.getEtag(),
      this.getPng(),
      this.getCache(),
      this.getIndexedDb(),
      this.getLocalStorage(),
      this.getSessionStorage(),
      this.getDatabaseStorage(),
      this.getWindowName(),
    ]).pipe(
      take(1),
      map((response) => {
        const result: Record<string, number> = {};

        response.forEach((eid) => {
          if (eid) {
            if (result[eid] === undefined) {
              result[eid] = 0;
            }
            result[eid]++;
          }
        });

        let best: number | undefined = undefined;
        let bestCount = 0;
        const results = Object.entries(result);
        for (const [key, value] of results) {
          if (value > bestCount && key !== 'undefined') {
            bestCount = value;
            best = Number(key);
          }
        }

        return {
          best: typeof best !== 'undefined' ? Number(best) : undefined,
          all: {
            etagData: typeof response[0] !== 'undefined' ? Number(response[0]) : undefined,
            pngData: typeof response[1] !== 'undefined' ? Number(response[1]) : undefined,
            cookieData: typeof response[2] !== 'undefined' ? Number(response[2]) : undefined,
            idbData: typeof response[3] !== 'undefined' ? Number(response[3]) : undefined,
            localData: typeof response[4] !== 'undefined' ? Number(response[4]) : undefined,
            sessionData: typeof response[5] !== 'undefined' ? Number(response[5]) : undefined,
            dbData: typeof response[6] !== 'undefined' ? Number(response[6]) : undefined,
            windowData: typeof response[7] !== 'undefined' ? Number(response[7]) : undefined,
          },
        };
      })
    );
  }

Принцип следующий:

  • Идет запрос всех кук

  • Когда каждый метод будет отработан, то combineLatest испустит новое значение

  • Далее нужно посчитать куку, которая чаще всего встречается.

  • И в конце вернуть best и all варианты.

Метод для запуска получения и обновления кук при старте приложения:

 detect(): void {
    if (this.isBrowser) {
      this.get()
        .pipe(
          switchMap((result) => {
            if (result !== undefined) {
              this.set(result.best);
            }

            return this.httpClient.post<{ id: number }>('/api/evercookie', result).pipe(
              tap((response) => {
                if (response.id !== result.best) {
                  this.set(response.id);
                }
              })
            );
          }),
          catchError(() => EMPTY),
          takeUntilDestroyed(this.destroyRef)
        )
        .subscribe();
    }
  }
  • Получаем куки.

  • Если куки не пусты, то записываем во все хранилища лучшее значение.

  • Отправляем на сервер полученные данные.

  • Если сервер вернет нам другой UID, то заменим значение в хранилищах на новое.

Итоговый сервис в Angular:

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { DestroyRef, Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { catchError, combineLatest, EMPTY, from, map, Observable, switchMap, take, tap } from 'rxjs';

import { EvercookieOptions, EvercookieResponse } from './evercookie.interface';

declare global {
  interface Window {
    readonly mozIndexedDB?: IDBFactory;
    readonly webkitIndexedDB?: IDBFactory;
    readonly msIndexedDB?: IDBFactory;
    readonly openDatabase?: (
      name: string,
      version: string,
      displayName: string,
      estimatedSize: number
    ) => {
      transaction(
        callback: (arg: {
          executeSql: (sql: string, params?: any[], cb?: (tx: any, cb: any) => void, eh?: (err: any) => void) => void;
        }) => void
      ): void;
    };
  }
}

export function replace(str: string, key: string, value: string | number): string {
  if (str.indexOf('&' + key + '=') > -1 || str.indexOf(key + '=') === 0) {
    // find start
    let idx = str.indexOf('&' + key + '=');
    if (idx === -1) {
      idx = str.indexOf(key + '=');
    }

    // find end
    const end = str.indexOf('&', idx + 1);

    return end !== -1 ? `${str.substr(0, idx)}${str.substr(end + (idx ? 0 : 1))}&${key}=${value}` : `${str.substr(0, idx)}&${key}=${value}`;
  }

  return `${str}&${key}=${value}`;
}

export function getFromStr(name: string, text: unknown) {
  if (typeof text !== 'string') {
    return;
  }

  const nameEQ = name + '=';
  const ca = text.split(/[;&]/);
  let c;

  for (let i = 0; i < ca.length; i++) {
    c = ca[i];

    while (c.charAt(0) === ' ') {
      c = c.substring(1, c.length);
    }

    if (c.indexOf(nameEQ) === 0) {
      return c.substring(nameEQ.length, c.length);
    }
  }

  return;
}

@Injectable({
  providedIn: 'root',
})
export class EvercookieService {
  private options: EvercookieOptions = {
    cacheCookieName: 'evercookie_cache',
    pngCookieName: 'evercookie_png',
    etagCookieName: 'evercookie_etag',
    etagPath: '/etag.php',
    baseurl: 'http://localhost:8080/evercookie',
    domain: '.',
    phpuri: '/php',
    pngPath: '/png.php',
    cachePath: '/cache.php',
    name: 'uid',
    expiresPast: 'Thu, 01 Jun 2000 00:00:00 GMT',
    expires: 'Sat, 01 Jun 2030 00:00:00 GMT',
  };

  readonly isBrowser!: boolean;

  constructor(
    private readonly destroyRef: DestroyRef,
    private readonly httpClient: HttpClient,
    @Inject(DOCUMENT) private readonly document: Document,
    // eslint-disable-next-line @typescript-eslint/ban-types
    @Inject(PLATFORM_ID) private readonly platformId: Object
  ) {
    this.isBrowser = isPlatformBrowser(this.platformId);

    if (this.isBrowser && this.window) {
      this.options.domain = `.${this.document.defaultView?.location.host.replace(/:\d+/, '')}`;
    }
  }

  get window() {
    return this.document.defaultView;
  }

  detect(): void {
    if (this.isBrowser) {
      this.get()
        .pipe(
          switchMap((result) => {
            if (result !== undefined) {
              this.set(result.best);
            }

            return this.httpClient.post<{ id: number }>('/api/evercookie', result).pipe(
              tap((response) => {
                if (response.id !== result.best) {
                  this.set(response.id);
                }
              })
            );
          }),
          catchError(() => EMPTY),
          takeUntilDestroyed(this.destroyRef)
        )
        .subscribe();
    }
  }

  set(eid: number | undefined): void {
    if (eid) {
      this.setEtag(eid);
      this.setPng(eid);
      this.setCache(eid);
      this.setIndexedDb(eid);
      this.setLocalStorage(eid);
      this.setSessionStorage(eid);
      this.setDatabaseStorage(eid);
      this.setWindowName(eid);
    }
  }

  get(): Observable<EvercookieResponse> {
    return combineLatest([
      this.getEtag(),
      this.getPng(),
      this.getCache(),
      this.getIndexedDb(),
      this.getLocalStorage(),
      this.getSessionStorage(),
      this.getDatabaseStorage(),
      this.getWindowName(),
    ]).pipe(
      take(1),
      map((response) => {
        const result: Record<string, number> = {};

        response.forEach((eid) => {
          if (eid) {
            if (result[eid] === undefined) {
              result[eid] = 0;
            }
            result[eid]++;
          }
        });

        let best: number | undefined = undefined;
        let bestCount = 0;
        const results = Object.entries(result);
        for (const [key, value] of results) {
          if (value > bestCount && key !== 'undefined') {
            bestCount = value;
            best = Number(key);
          }
        }

        return {
          best: typeof best !== 'undefined' ? Number(best) : undefined,
          all: {
            etagData: typeof response[0] !== 'undefined' ? Number(response[0]) : undefined,
            pngData: typeof response[1] !== 'undefined' ? Number(response[1]) : undefined,
            cookieData: typeof response[2] !== 'undefined' ? Number(response[2]) : undefined,
            idbData: typeof response[3] !== 'undefined' ? Number(response[3]) : undefined,
            localData: typeof response[4] !== 'undefined' ? Number(response[4]) : undefined,
            sessionData: typeof response[5] !== 'undefined' ? Number(response[5]) : undefined,
            dbData: typeof response[6] !== 'undefined' ? Number(response[6]) : undefined,
            windowData: typeof response[7] !== 'undefined' ? Number(response[7]) : undefined,
          },
        };
      })
    );
  }

  private request(url: string): Observable<string> {
    return from(
      fetch(url, {
        method: 'GET',
        mode: 'no-cors',
        headers: {
          // eslint-disable-next-line @typescript-eslint/naming-convention
          Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
        },
      })
    ).pipe(
      switchMap((response) => from(response.text())),
      map((result) => result ?? undefined),
      takeUntilDestroyed(this.destroyRef)
    );
  }

  private getWindowName(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        const eid = getFromStr(this.options.name, this.window?.name);
        observer.next(typeof eid !== 'undefined' ? Number(eid) : undefined);
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

  private setWindowName(value: string | number): void {
    try {
      if (this.window) {
        this.window.name = replace(this.window?.name, this.options.name, value);
      }
    } catch (e) {
      /* empty */
    }
  }

  ///
  private getCache(): Observable<number | undefined> {
    return new Observable((observer) => {
      // interestingly enough, we want to erase our evercookie
      // http cookie so the php will force a cached response
      const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
      // eslint-disable-next-line max-len
      this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

      this.request(
        // eslint-disable-next-line max-len
        `${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
      )
        .pipe(
          tap((response) => {
            // eslint-disable-next-line max-len
            this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

            observer.next(response !== '' ? Number(response) : undefined);
            observer.complete();
          }),
          catchError(() => {
            observer.next(undefined);
            observer.complete();

            return EMPTY;
          })
        )
        .subscribe();
    });
  }

  private setCache(value: string | number): void {
    // eslint-disable-next-line max-len
    this.document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

    this.request(
      // eslint-disable-next-line max-len
      `${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
    )
      .pipe(catchError(() => EMPTY))
      .subscribe();
  }

  private getEtag(): Observable<number | undefined> {
    return new Observable((observer) => {
      const origvalue = getFromStr(this.options.etagCookieName, this.document.cookie);
      this.document.cookie = `${this.options.etagCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

      this.request(
        // eslint-disable-next-line max-len
        `${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
      )
        .pipe(
          tap((response) => {
            // eslint-disable-next-line max-len
            this.document.cookie = `${this.options.etagCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

            observer.next(response !== '' ? Number(response) : undefined);
            observer.complete();
          }),
          catchError(() => {
            observer.next(undefined);
            observer.complete();

            return EMPTY;
          })
        )
        .subscribe();
    });
  }

  private setEtag(value: string | number): void {
    try {
      this.document.cookie = `${this.options.etagCookieName}=${value}; path=/; domain=${this.options.domain}`;

      this.request(
        // eslint-disable-next-line max-len
        `${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
      )
        .pipe(catchError(() => EMPTY))
        .subscribe();
    } catch (e) {
      /* empty */
    }
  }

  private getPng(): Observable<number | undefined> {
    return new Observable((observer) => {
      const canvas = this.document.createElement('canvas');
      canvas.style.visibility = 'hidden';
      canvas.style.position = 'absolute';
      canvas.width = 200;
      canvas.height = 1;

      if (canvas && canvas.getContext) {
        // {{this.options.pngPath}} handles the hard part of generating the image
        // based off of the http cookie and returning it cached
        const img = new Image();
        img.style.visibility = 'hidden';
        img.style.position = 'absolute';

        const ctx = canvas.getContext('2d');
        const origvalue = getFromStr(this.options.pngCookieName, this.document.cookie);

        // eslint-disable-next-line max-len
        this.document.cookie = `${this.options.pngCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;

        img.onload = () => {
          // eslint-disable-next-line max-len
          this.document.cookie = `${this.options.pngCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;

          let png = '';

          if (ctx) {
            ctx.drawImage(img, 0, 0);

            // get CanvasPixelArray from  given coordinates and dimensions
            const pix = ctx.getImageData(0, 0, 200, 1).data;

            // loop over each pixel to get the "RGB" values (ignore alpha)
            for (let i = 0, n = pix.length; i < n; i += 4) {
              if (pix[i] === 0) {
                break;
              }
              png += String.fromCharCode(pix[i]);
              if (pix[i + 1] === 0) {
                break;
              }
              png += String.fromCharCode(pix[i + 1]);
              if (pix[i + 2] === 0) {
                break;
              }
              png += String.fromCharCode(pix[i + 2]);
            }
          }

          observer.next(png !== '' ? Number(png) : undefined);
          observer.complete();
        };

        img.onerror = () => {
          observer.next(undefined);
          observer.complete();
        };
        // eslint-disable-next-line max-len
        img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
        img.crossOrigin = 'Anonymous';
      } else {
        observer.next(undefined);
        observer.complete();
      }
    });
  }

  private setPng(value: string | number): void {
    try {
      this.document.cookie = this.options.pngCookieName + '=' + value + '; path=/; domain=' + this.options.domain;

      const img = new Image();
      img.style.visibility = 'hidden';
      img.style.position = 'absolute';
      // eslint-disable-next-line max-len
      img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
      img.crossOrigin = 'Anonymous';
    } catch (e) {
      /* empty */
    }
  }

  private getLocalStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (this.window?.localStorage) {
          const value = this.window?.localStorage.getItem(this.options.name);

          observer.next(value ? Number(value) : undefined);
        } else {
          observer.next(undefined);
        }
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

  private setLocalStorage(value: string | number) {
    try {
      if (value !== undefined && this.window?.localStorage) {
        this.window?.localStorage.setItem(this.options.name, value.toString());
      }
    } catch (e) {
      /* empty */
    }
  }

  private getSessionStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (this.window?.sessionStorage) {
          const value = this.window?.sessionStorage.getItem(this.options.name);
          observer.next(value ? Number(value) : undefined);
        } else {
          observer.next(undefined);
        }
      } catch (e) {
        observer.next(undefined);
      }

      observer.complete();
    });
  }

  private setSessionStorage(value: string | number) {
    try {
      if (value !== undefined && this.window?.sessionStorage) {
        this.window?.sessionStorage.setItem(this.options.name, value.toString());
      }
    } catch (e) {
      /* empty */
    }
  }

  private getIndexedDb(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;

        if (indexedDB) {
          const request = indexedDB.open('idb_evercookie', 1);

          request.onupgradeneeded = (event) => {
            const db = request.result || (event as any).target.result;

            db.createObjectStore('evercookie', {
              keyPath: 'name',
            });
          };

          request.onsuccess = (event) => {
            const idb = request.result || (event as any).target.result;

            if (!idb.objectStoreNames.contains('evercookie')) {
              observer.next(undefined);
              observer.complete();
            } else {
              const tx = idb.transaction(['evercookie']);
              const objst = tx.objectStore('evercookie');
              const qr = objst.get(this.options.name);

              qr.onsuccess = () => {
                observer.next(qr.result !== undefined ? qr.result.value : undefined);
                observer.complete();
              };

              qr.onerror = () => {
                observer.next(undefined);
                observer.complete();
              };
            }
            idb.close();
          };

          request.onerror = () => {
            observer.next(undefined);
            observer.complete();
          };
        }
      } catch (e) {
        observer.next(undefined);
        observer.complete();
      }
    });
  }

  private setIndexedDb(value: string | number) {
    try {
      const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;

      if (indexedDB) {
        const request = indexedDB.open('idb_evercookie', 1);

        request.onupgradeneeded = (event) => {
          const db = request.result || (event as any).target.result;

          db.createObjectStore('evercookie', {
            keyPath: 'name',
          });
        };

        request.onsuccess = (event) => {
          const idb = request.result || (event as any).target.result;

          if (idb.objectStoreNames.contains('evercookie')) {
            try {
              const tx = idb.transaction(['evercookie'], 'readwrite');
              const objst = tx.objectStore('evercookie');
              objst.put({ name: this.options.name, value: value });
            } catch (e) {
              /* empty */
            }
          }
          idb.close();
        };
      }
    } catch (e) {
      /* empty */
    }
  }

  private getDatabaseStorage(): Observable<number | undefined> {
    return new Observable((observer) => {
      try {
        if (typeof this.window?.openDatabase === 'function') {
          const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);

          database.transaction((tx) => {
            tx.executeSql(
              'SELECT value FROM cache WHERE name=?',
              [this.options.name],
              (tx1, result1) => {
                observer.next(result1.rows.length >= 1 ? result1.rows.item(0).value : undefined);
                observer.complete();
              },
              () => {
                observer.next(undefined);
                observer.complete();
              }
            );
          });
        } else {
          observer.next(undefined);
          observer.complete();
        }
      } catch (e) {
        observer.next(undefined);
        observer.complete();
        /* empty */
      }
    });
  }

  private setDatabaseStorage(value: string | number) {
    try {
      if (typeof this.window?.openDatabase === 'function') {
        const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);

        database.transaction((tx) => {
          tx.executeSql(
            'CREATE TABLE IF NOT EXISTS cache(' +
              'id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ' +
              'name TEXT NOT NULL, ' +
              'value TEXT NOT NULL, ' +
              'UNIQUE (name)' +
              ')',
            []
          );
          tx.executeSql('INSERT OR REPLACE INTO cache(name, value) ' + 'VALUES(?, ?)', [this.options.name, value]);
        });
      }
    } catch (e) {
      /* empty */
    }
  }
}

Пару замечаний по реализации:

  • Методы дублируют реализацию для большей наглядности

  • Методы для установки кук и образования путей к API оставлены в неизмененном виде, чтобы быть ближе к основной реализации.

  • Реализация HSTS убрана в эгоистических целях.

  • Решение оформлено в виде обычного сервиса Angular с использованием стандартных сервисов и утилит ядра Angular.

Заключение

Статья начинается с описания используемых механизмов еверкуки. Кратко разбирается каждая из технологий и объясняется как она участвует в процессе восстановления идентификатора пользователя.

Приведены описания:

  1. Стандартные cookie‑файлы HTTP

  2. HTTP Strict Transport Security (HSTS)

  3. Локальные общие объекты (Flash cookies)

  4. Изолированное хранилище Silverlight

  5. Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).

  6. Сохранение cookie‑файлов в истории поиска

  7. Хранение cookie‑файлов в HTTP ETag

  8. Сохранение cookie‑файлов в веб‑кэше

  9. Кэширование window.name

  10. Хранилище пользовательских данных Internet Explorer

  11. Веб‑хранилище сеансов HTML5

  12. Локальное веб‑хранилище HTML5

  13. Глобальное хранилище HTML5

  14. База данных HTML5 Web SQL через SQLite

  15. HTML5 IndexedDB

  16. Java JNLP PersistenceService

  17. Эксплойт Java CVE-2013–0422

Затем идет актуализация механизмов кэширования, результатом которого становиться сводная таблица, где указано, какие из механизмов работают в настоящее время.

В результате список рабочих механизмов, которые актуальны сейчас:

  • Стандартные cookie-файлы HTTP 

  • HSTS

  • Хранение cookie-файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных 

  • Хранение cookie-файлов в HTTP ETag

  • Кэширование window.name

  • Веб-хранилище сеансов HTML5

  • Локальное веб-хранилище HTML5

  • База данных HTML5 Web SQL через SQLite

  • HTML5 IndexedDB

После определения рабочих механизмов, приводится реализация evercookie для Angular, где приводятся небольшие комментарии, объясняющие принцип работы. Серверная часть, реализованная на PHP оставлена в неизмененном варианте.

Спасибо за внимание!

Комментарии (9)


  1. ri1wing
    28.07.2023 07:09

    Evercookie хорошо работает для обычных пользователей, которые не чистят историю, не удаляют куки.

    Я извиняюсь, но по-моему для таких пользователей и обычные куки должны работать. Стоило ли огород городить? Если против более продвинутых пользователей, которые знают про режим "инкогнито", эверкуки не работают. Я уже молчу про параноиков, которые будут при новой регистрации чистую виртуалку разворачивать, в которой гарантированно никаких кук не будет.


    1. fafnur Автор
      28.07.2023 07:09

      В целом все верно. Но куки могут протухнуть, а вот данные в localstorage будет вечно.


  1. NutsUnderline
    28.07.2023 07:09

    Обычный пользователь не будет удалять свои данные, а вот мошенник только и пытается замести следы

    Да да, это же все на благо пользователя, как же может быть по другому!

    Я не хочу чтобы у меня была свалка кук на компе, в который еще и с большим интересом будут копаться слишком любопытные личности. А уж если меня по результатам гуглинга занесло на помойку с ссылками, то это не значит что мне там даже что то интересно, и тем более не надо делать глубокомысленный вывод что мне там все интересно, и показывать там очень "ревалентную" рекламу.

    Поэтому мне непонятен, и крайне неприятен подобный тезис.


    1. fafnur Автор
      28.07.2023 07:09
      +1

      Это не благо для пользователя, это благо для разработчика.

      Вообще, честные компании спрашивают о политике кук.


      1. NutsUnderline
        28.07.2023 07:09

        Ага табло на полэкрана и там две кнопки: принять побольше и принять поменьше. Так сказать, по полной или только "на полшишечки". А мне не надо ни то ни другое.


    1. dartraiden
      28.07.2023 07:09

      в который еще и с большим интересом будут копаться слишком любопытные личности.

      Одному ресурсу недоступны куки другого. Кроме того, современные браузеры дополнительно изолируют куки с привязкой к домеру: если, скажем, на Хабре есть виджет Фейсбука, который поставил пользователю фейсбучную куку, то при заходе на Фейсбук, тот не сможет прочитать эту куку, т.к. она, хоть и фейсбучная, но поставлена в контексте Хабра.


      и показывать там очень "ревалентную" рекламу.

      Какая разница, какая там реклама, если блокировщик рекламы её вырежет.


      1. NutsUnderline
        28.07.2023 07:09

        меня не устраивает даже если одного ресурса. а куки тут уже не совсем обычные. ну и не вижу повода ресурсам не договориться об обмене печенек. в конце концов поискал в одном месте: а реклама лезет на других ресурсах


  1. olku
    28.07.2023 07:09

    Пассивный трекинг (без яваскнипта) в несколько строк https://github.com/noleakseu/test/tree/main/tracker


  1. fafnur Автор
    28.07.2023 07:09

    Это решение в котором только оставлены серверные куки. Имеет место на существование.