Назначение: определить в точности, как мы доказываем, что конфигурация отпечатка — и собранное ядро —
согласованы и достаточно необнаружимы, чтобы отгружать. Это релизный гейт, на который ссылаются
CHROMIUM_BUILD_AND_UPDATE §5 и core/tests/consistency/. Обнаружение рождается из несогласованности между
сигналами, а не из отдельных значений, поэтому набор проверяет согласованность, а не просто наличие.
1. Что мы проверяем (три слоя)
- Утверждения согласованности (наши, детерминированные): внутренние противоречия → жёсткое падение.
- Внешние пробы (сторонние детекторы): прогон без окна против ядра, оценка результата.
- Проверки на артефакты: никаких следов подмены JS-шимом (то, что выдаёт дешёвые антидетекты).
2. Утверждения согласованности (падать, а не предупреждать)
| ID | Проверка | Правило |
|---|---|---|
| C1 | UA ⇄ UA-CH | оба выведены из одних os+browser.version; никакого несоответствия, набранного руками |
| C2 | GPU ⇄ ОС | вендор и рендерер WebGL/WebGPU правдоподобны для ОС (никаких GPU Apple на Windows) |
| C2b | Возможности WebGL (измерение) | ЗАПИСАТЬ профиль возможностей WebGL (числовые пределы getParameter + getSupportedExtensions + точность шейдеров) — слой проб, не блокирует, только характеризует; делает остаток по возможностям после 0020 видимым, вместо того чтобы зелёный C2 его прятал |
| C2c | Обратное чтение WebGPU (измерение) | ЗАПИСАТЬ хэш FNV-1a обратного чтения цвета очистки через WebGPU copyTextureToBuffer→mapAsync→getMappedRange (читается ДВАЖДЫ ради детерминизма) — это путь сырого буфера, который пропускают перехваты canvas в 0021 (ISSUE-021b b). Слой проб, не блокирует, только характеризует: проходит на стоке (без шума, настоящее с хоста) И на собранном ядре (патч 0024, шум по семени), ПАДАЕТ только на ok, но НЕСТАБИЛЬНОМ (обратное чтение, случайное на каждый вызов). База без шума 5fc51dc5 → 126031b4 под 0024; чувствительность к семени доказана вне набора тестом на два семени под гейтом · Происхождение (B76, ISSUE-188): шум применяется только к байтам, которые copyTextureToBuffer 8-битной цветовой текстуры действительно положил в буфер, и только если отправка этой копии ВЫПОЛНИЛАСЬ. Каждая буферная команда, которую записывает энкодер, отмечает GPUBuffer-ы, которые она называет (источник и приёмник копии, источник copyBufferToTexture, приёмник copyTextureToBuffer, clearBuffer, resolveQuerySet); на submit() каждому упомянутому буферу задаётся один вопрос — выполнит ли Dawn эту отправку? (отображён или ожидает отображения — нет; израсходованный или заведомо недействительный участник либо дубликат — нет), — и отклонённая отправка не применяет НИЧЕГО из записанного, ни области, ни пометки, так что буфер, заполненный хостом и названный отклонённой копией, читается обратно бит в бит; queue.writeBuffer задаёт тот же вопрос своей цели (отклонённая запись — отображённый буфер, невыровненная, вне границ, нулевой длины — ничего не помечает, так что выключить шум ею нельзя). B75 спрашивал только буферы, несущие происхождение, и этот список прирастал ещё одним собратом на каждое ревью (отображённый приёмник, выход за границы, невыравненность); B76 вместо этого спрашивает каждый упомянутый буфер. Измерения «до → после» на собранной 152 — в worklog/core.md (B76). Вопрос задаётся каждому буферу, который называет любой КОНТЕЙНЕР, с B80 (ISSUE-190). B79 измерил, что зеркало всё ещё пропускало: буфер, до которого отправка добиралась только ЧЕРЕЗ ПРОХОД (setVertexBuffer, группа привязок), не был упомянут ни одной командой, записанной энкодером, поэтому Dawn отклонял отправку, а мы всё равно применяли копию — предзаполненный приёмник возвращался с 95 из 95 изменённых цветовых байт там, где сток не меняет ни одного, и это наш шум поверх собственных данных страницы, а не первозданное чтение. Теперь проход несёт свой родительский энкодер, и каждая называющая команда регистрируется в нём (setVertexBuffer / setIndexBuffer, setBindGroup на проходе любого типа, буферы непрямой отрисовки), а ссылки RENDER BUNDLE едут вместе с ним от его собственного энкодера через finish() до executeBundles. В WebGPU ровно четыре объекта, которые записывают команды, так что ни один контейнер не может назвать буфер незамеченным; семь последовательностей измерены как 95 → 0 каждая, и webgpu_submit_classifier.rs зелёный. Чего это НЕ закрывает — списка ПРИЧИН отклонения у Dawn: зеркало по-прежнему список запретов, полный решатель — это собственный QueueBase::APISubmit у Dawn, и ISSUE-190 держит его как оценённый срез после альфы. Тот же файл под гейтом закрепляет видимую странице расшифровку ошибок (тип и сообщение popErrorScope, число uncapturederror) как одинаковую у личности и у контроля и объясняет, почему два ответа со стороны Blink отвергнуты: область ошибок — это признак A1/A2/A4 из B77, а путь неперехваченной ошибки видит 0 от отклонённой отправки, которую страница обернула в собственную область. |
| C2d | WebGPU без порчи данных (страж) | Состязательно ДОКАЗАТЬ, что область действия шума патча 0024 оставляет НЕцветовые обратные чтения бит в бит: и чтение из вычислительного staging через copyBufferToBuffer, И область цветовой текстуры, ПЕРЕЗАПИСАННАЯ копией из буфера (путь пометки), возвращают точно известный образец. Слой проб, не блокирует, только характеризует; проходит на стоке (шума нет) и на собранном ядре (шум правильно уведён в сторону), ПАДАЕТ ТОЛЬКО на ok, но НЕ точном совпадении (шум 0024 просочился мимо белого списка форматов или пометки на вычислительные данные — настоящая регрессия порчи). Живой страж инварианта 0024 «никогда не портит вычислительные данные» |
| C3 | Шрифты ⇄ ОС | v1 (патч 0023): ГЛУБОКАЯ проекция — ОБЕ половины нативного шлюза FontCache::CreateFontPlatformData: подписные шрифты ОС из конфигурации сообщают о ПРИСУТСТВИИ (measureText/document.fonts.check) и подписные шрифты других ОС, что есть у хоста, СКРЫТЫ. Шрифт конфигурации, отсутствующий у хоста, ПОДМЕНЯЕТСЯ отдельной СКРЫТОЙ гарнитурой хоста (инъективная перестановка по семени); точность метрик — остаток (ISSUE-023) |
| C3b | Метрики шрифтов (измерение) | ЗАПИСАТЬ ширину measureText каждого подписного шрифта ОС из конфигурации (ширину ПОДМЕНЯЮЩЕЙ гарнитуры, а не настоящего шрифта целевой ОС) + утверждать ИНЪЕКТИВНОСТЬ (никакие два разных заявленных шрифта не совпадают по ширине) — слой проб, не блокирует, только характеризует; делает остаток по метрикам подмены видимым, вместо того чтобы зелёный C3 его прятал (зеркалит C2b) |
| C4 | Часовой пояс ⇄ прокси | v1 (патч 0014): ГЛУБОКАЯ проекция — наблюдаемый пояс Intl == config.locale.timezone (+ контролёр диапазона смещения); перекрёстная проверка географии выхода прокси (пояс Intl/Date достижим из выходного IP прокси) — остаток сетевого слоя на потом |
| C5 | WebRTC ⇄ прокси | v1 (ADR-0027, сетевая ИНВЕРСИЯ): НЕглубокий страж согласованности — ядро ЕСТЬ сырой Chromium M149, который по умолчанию прячет локальные IP за именем mDNS .local (WebRtcHideLocalIpsWithMdns, включено с M80), так что он маскирует их ПО ПОСТРОЕНИЮ (подделывать нечего). C5 собирает ICE-кандидатов WebRTC у движка ПРЯМО НА СТРАНИЦЕ (кандидатам host не нужны STUN/TURN — они приходят с локальных интерфейсов в момент setLocalDescription) и утверждает, что ни один кандидат host не открыл буквальный IP локального интерфейса (любого диапазона — сырой адрес есть признак деанонимизации, какого бы класса он ни был; srflx/relay отражают путь STUN/прокси, а не машину). Маскировка .local проверяется на подлинную случайную UUID-форму Chrome (8-4-4-4-12 шестнадцатеричных); не-UUID .local (настоящее имя машины или плохая подделка) записывается отдельно как MDNS-SUSPECT: — это не утечка IP, но ядро, которое производит только подозрительные имена, не проходит утверждение host:mdns под гейтом ниже (ISSUE-028 b). Классификация — это протестированный чистый помощник на Rust (classify_ice_candidate), а не JS (аудит C-2). Сводка наблюдаемых кандидатов ЗАПИСЫВАЕТСЯ (как JA3 у C10), а не сверяется на равенство. PASS = «утечки нет» (проверка fail-OPEN — она ПАДАЕТ только на положительно разобранном сыром адресе хоста, так что пустой сбор, probe-error или webrtc-unavailable никогда не дают ложного падения, и ни один офлайновый строитель Signals не уронит её через Default). Чтобы гниющая проба не проходила незаметно, живые тесты под гейтом утверждают ПОЛОЖИТЕЛЬНЫЙ сигнал — c5.detail обязана содержать host:mdns (настоящий маскированный кандидат действительно наблюдался), — так что регрессия с выключенным mDNS, удалённым WebRTC или сломанной пробой ловится там, где настоящий интерфейс есть (аудит ISSUE-028). Проверено живьём: сток M150 и собранный M149 оба дают host:mdns:1. НЕглубокая (проходят оба провайдера). Ловит будущий --disable-features=WebRtcHideLocalIpsWithMdns (сырой IP хоста → падение). Отложено (задокументированный остаток, как география выхода прокси у C4/C9): нога «srflx/публичный IP == выход прокси» требует сервера STUN и настоящего прокси и едет на слое сети и прокси |
| C6 | Экран | dpr × разрешение + colorDepth — настоящая комбинация устройства |
| C7 | Шум canvas/WebGL | v1 (патч 0021): утверждает СТАБИЛЬНОСТЬ (НЕглубокая, ISSUE-021d) — двойное чтение canvas детерминировано (тот же хэш), так что случайный на каждый вызов шум (классический признак) ловится и блокирует вердикт. Это свойство качества, а НЕ глубокая проекция: оно держится и на стоке (сток читает одинаковые байты), поэтому она НЕ .as_deep() и не входит в собранный deep_checks. НАЛИЧИЕ шума против стока (глубокая нога «различается между профилями» / чувствительность к семени) теперь ДОКАЗАНО вне набора тестом на два семени под гейтом built_core_canvas_and_audio_noise_are_seed_sensitive (два запуска собранного ядра, различающиеся только canvas.seed → разные хэши, каждый по-прежнему стабилен) — ISSUE-021b (a) ЗАКРЫТА. C7 ОСТАЁТСЯ неглубокой в карте (стабильность держится на стоке); чувствительность к семени — это реляционное свойство двух запусков, а не проверка внутри одной карты · Оба маршрута (B78, патч 0034, ISSUE-174): маршрут WebGL2 PIXEL_PACK_BUFFER — readPixels(…, offset) в привязанный PBO, вычитываемый getBufferSubData — несёт ТУ ЖЕ маску и тот же ключ, что и прямой readPixels (область обратного чтения на WebGLBuffer, применённая к байтам, которые чтение пересекает, и переносимая copyBufferSubData), так что у одного canvas одна картина, каким бы маршрутом её ни прочитали. Запись в буфер никогда не роняет область — она забирает обратно байты, которые ЗАПИСАЛА (B79, ISSUE-194): забранные байты — это точная битовая карта, а запись — это РАСКЛАДКА, а не диапазон байт, поэтому упакованное чтение не трогает пропуск PACK и заполнение между строками, непроецируемое чтение забирает своё изображение, а не остаток буфера, а отрисовка с transform feedback забирает диапазон, привязанный по её индексу; байт альфы решает вопрос о пропуске при альфе 0 только пока он ещё принадлежит самому обратному чтению. Закреплено ДВУМЯ тестами под гейтом в webgl_pbo_noise.rs — b78_webgl2_pbo_route_reads_the_same_noised_pixel (прямой == PBO пиксель в пиксель, оба с шумом; чтение из пользовательского фреймбуфера первозданно; коды ошибок GL как у стока) и b79_pbo_exclusion_takes_the_write_footprint_and_nothing_more (восемь последовательностей записи, каждая из которых до третьего круга оставляла 13–757 цветовых байт первозданными, и GL_INVALID_VALUE, какого сток не поднимает, — после все по 0). Собственный хэш C7 принадлежит прямому маршруту и не сдвинулся. |
| C7c | Межинтерфейсный premul у canvas (патч 0021 / ISSUE-021c a) | НЕглубокая согласованность — НЕпрозрачный canvas, прочитанный через toBlob и через toDataURL, обязан декодироваться в ИДЕНТИЧНЫЕ пиксели. Исправление premul заставляет toBlob (canvas_async_blob_creator.cc) шуметь по НЕпредумноженным пикселям с прямой альфой, ровно как это делает путь toDataURL, — до исправления toBlob шумел по ПРЕДУМНОЖЕННой подложке, так что один и тот же canvas, прочитанный двумя способами, расходился (межинтерфейсный признак), а предумноженный канал мог превысить альфу (признак невозможного premul). Держится на стоке (шума нет ⇒ тривиально идентично) И на исправленном собранном ядре с включённым шумом; отрицательная разница — это провал измерения. Доказано живьём на обоих провайдерах |
| C8 | Железо | ядра и память взаимно правдоподобны и соответствуют классу устройства |
| C9 | Языки | v1 (патч 0012): ГЛУБОКАЯ проекция — наблюдаемый УПОРЯДОЧЕННЫЙ navigator.languages == locale.languages из конфигурации И navigator.language == locale.language, плюс сохранённые ноги внутренней согласованности (language==languages[0], правильно оформленный основной). Обе половины нативные: navigator.languages (Blink NavigatorLanguage, главный фрейм и воркеры) и заголовок Accept-Language (браузерный ComputeAcceptLanguage, байт в байт как настройка настоящего Chrome). Пустое наблюдение ⇒ НЕглубокое падение измерения. Список JS утверждается здесь; исходящий заголовок измеряет C9b |
| C9b | Заголовок Accept-Language (измерение) | ЗАПИСАТЬ исходящий ЗАПРОСНЫЙ заголовок Accept-Language — снимается на стороне сервера страницей-эхом на loopback (JS не может прочитать заголовок запроса собственного документа). Слой проб, не блокирует, только характеризует (как C2b/C3b): записывает заголовок (проходит на стоке и на собранном), а тест под гейтом на собранном ядре утверждает, что он проецирует конфигурацию (en-US,en;q=0.9), — это сетевая половина согласованности C9, чтобы navigator.languages и заголовок не могли молча разойтись (патч 0012 / ISSUE-025 b) |
| C10 | Отпечаток TLS ⇄ браузер | v1 (ADR-0027, сетевая ИНВЕРСИЯ): НЕглубокий страж согласованности — ядро ЕСТЬ сырой Chromium M149 (ADR-0007), так что его ClientHello и JA3 в TLS — это Chrome ПО ПОСТРОЕНИЮ (подделывать нечего). C10 снимает настоящий ClientHello на loopback-слушателе https (сырой TcpListener — никакого TLS-крейта; ClientHello идёт открытым текстом, до обмена ключами) и утверждает, что он имеет форму настоящего Chrome: есть GREASE + предложен TLS 1.3 + ALPN как у Chrome (h2,http/1.1) + X25519, — а НЕ рукопожатие Chromium по умолчанию, curl или прокси, терминирующего TLS. Строка JA3 ЗАПИСЫВАЕТСЯ, а не сверяется на равенство (эталонный JA3 хрупок между версиями, GREASE и перетасовкой расширений на каждое соединение — как «никаких молчащих измерений» у C2b/C3b/C9b). Сырой JA4 (FoxIO JA4_r, без хэша) тоже записывается: в отличие от JA3 он сортирует свои списки шифров и расширений, поэтому СТАБИЛЕН при перетасовке на каждое соединение (доказано живьём — сток M150 и собранный M149 дают байт в байт одинаковые a, шифры и расширения JA4, различаясь только алгоритмами подписи) → это устойчивый к версиям, закрепляемый сигнал, на который может ключеваться будущий гейт на каждый мажор (ISSUE-027 f). Бит SNI в JA4 читается как i, потому что цель пробы — loopback-IP (Chrome не шлёт SNI на голый IP). НЕглубокая (сток проходит тоже — движок ЕСТЬ Chrome), поэтому не входит ни в один набор deep_checks. Приор реализма «browser.version ↔ мажор движка» — это забота времени конфигурации (C10_VERSION_MALFORMED в validate.rs плюс пин при запуске профиля против активного провайдера), а не этой проверки на замороженной фикстуре |
| C11 | HTTP/2 ⇄ браузер | v1 (ADR-0027, сетевая ИНВЕРСИЯ): НЕглубокий страж согласованности (близнец C10) — ядро ЕСТЬ сырой Chromium M149, так что его SETTINGS в HTTP/2 — это Chrome ПО ПОСТРОЕНИЮ. C11 снимает их на loopback-пробе h2, терминирующей TLS (rustls + самоподписанный сертификат rcgen + ALPN h2; кадр SETTINGS идёт ВНУТРИ туннеля TLS, в отличие от открытого ClientHello у C10; браузер получает только для набора --ignore-certificate-errors), утверждает форму настоящего Chrome (SETTINGS_ENABLE_PUSH == 0) и ЗАПИСЫВАЕТ ПОЛНЫЙ отпечаток Akamai 1:65536;2:0;4:6291456;6:262144 | 15663105 | m,a,s,p — SETTINGS + WINDOW_UPDATE + порядок псевдозаголовков (m=:method a=:authority s=:scheme p=:path, декодировано упрощённым HPACK из кадра HEADERS запроса — самый сильный различитель между браузерами), одинаковый на стоке M150 и собранном M149, БОЛЕЕ устойчивый к версиям, чем JA4. НЕглубокая (проходят оба провайдера). QUIC/h3 → C13 (ISSUE-027 c) |
| C13 | QUIC ⇄ браузер | v1 (ADR-0027, сетевая ИНВЕРСИЯ): НЕглубокий страж согласованности (четвёртая нога, после C10/C11/C5) — ядро ЕСТЬ сырой Chromium M149, так что его QUIC — это Chrome ПО ПОСТРОЕНИЮ. C13 заставляет браузер открыть QUIC к loopback-origin (--origin-to-force-quic-on = LaunchSpec::force_quic_on, задаётся ДО запуска — флаг запуска, в отличие от переходов после запуска у C10/C11; иначе Chrome пробует QUIC только после подсказки Alt-Svc: h3, которую сырая проба послать не может), снимает QUIC Initial на loopback-пробе UDP (QuicInitialProbe — сырой UdpSocket, без рукопожатия) и утверждает, что НЕзашифрованный длинный заголовок (RFC 9000 §17.2; верхний полубайт байта 0 не защищён заголовочной защитой, RFC 9001 §5.4) имеет форму настоящего Chrome: длинный заголовок Initial + узнаваемая версия QUIC (v1 0x00000001 / v2 0x6b3343cf) + 8-байтный DCID (длина идентификатора соединения у Chrome) + пустой токен в первом полёте. Строка отпечатка version|dcid=..|scid=..|token=.. ЗАПИСЫВАЕТСЯ (как JA3 у C10 и отпечаток Akamai у C11), а не сверяется на равенство. НЕглубокая — проверено живьём: сток M150 и собранный M149 ОБА выдают одинаковое 00000001|dcid=8|scid=0|token=0 → оба проходят → characterize == Matches у обоих. Пустой снимок ⇒ ветвь пропуска C13 (неглубокое падение с пометкой «разобраться», как у C10/C11), никогда не потолок. Криптооснова v2 ПРИЗЕМЛИЛАСЬ 2026-07-04 (quic_decrypt.rs, ISSUE-027 c): QUIC Initial защищён ДЕТЕРМИНИРОВАННЫМИ ключами без секрета (HKDF-SHA256 от DCID и опубликованной соли, RFC 9001 §5.2), так что расшифровать его может любой наблюдатель — derive_client_initial_keys (HKDF) + header_protection_mask (AES-128-ECB, RFC 9001 §5.4) + decrypt_initial_payload (AES-128-GCM open), и всё это закреплено против разобранного примера из RFC 9001, приложение A (точные ключ/iv/hp и маска HP) плюс круг по самозащищённому пакету. Извлечение ClientHello v2b-1 ПРИЗЕМЛИЛОСЬ 2026-07-04: client_hello_from_initials собирает внутренний ClientHello TLS из снятых Initial — Chrome ДРОБИТ свой ClientHello на ~1.7 KB по нескольким Initial примерно по 1250 B (расшифровка одного пакета падает на обычном случае — найдено живым тестом, а не предположено), поэтому проба удерживает ВСЕ Initial, а collect_crypto накапливает поток CRYPTO по смещению ЧЕРЕЗ пакеты (группируя по DCID; проходит мимо ACK/CONNECTION_CLOSE по RFC 9000 §12.4; ограничено ради защиты от DoS), после чего extract_client_hello возвращает только ПОЛНОЕ рукопожатие. Проверено живьём: 6 снятых Initial собранного ядра M149 собираются в целый ClientHello на 1707–1737 байт, 3 из 3 прогонов. Разбор QUIC-JA3 v2b-2a ПРИЗЕМЛИЛСЯ 2026-07-04 (quic_ja3.rs): parse_client_hello_fp проходит по собранному ClientHello (RFC 8446 §4.1.2) → строка JA3 (версия, шифры, расширения, кривые — GREASE убран по RFC 8701/9287) плюс идентификаторы transport_parameters QUIC (расширение 0x0039, специфичный для QUIC различитель, отсутствующий в JA3 от TCP-TLS). Как и C10, записывает И сырой JA3 (чувствительный к порядку → Chrome тасует расширения), И стабильный вариант с отсортированными расширениями. Проверено живьём: собранный M149 выдаёт 771,4865-4866-4867,10-13-16-27-43-45-51-57-…,4588-29-23-24,|tp=1-3-4-… — декодировано в настоящие шифры TLS 1.3 у Chrome + X25519MLKEM768 + ECH + 12 транспортных параметров; отсортированный отпечаток ИДЕНТИЧЕН между прогонами (стабилен вопреки перетасовке). v2b-2b ПОДКЛЮЧЕНО 2026-07-04 — QUIC теперь ИЗМЕРЯЕТСЯ на полную глубину (структура + JA3): снимок C13 в score_running_core ждёт, пока ClientHello соберётся из снятых Initial, затем ЗАПИСЫВАЕТ quic_ja3 в Signals; проверка C13 выносит его в свою подробность (записано, не под гейтом — как JA3 у C10). Проверено живьём на ОБОИХ ядрах — сток M150 и собранный M149 записывают ИДЕНТИЧНОЕ 771,4865-4866-4867,10-13-16-…-57-…,4588-29-23-24,|tp=1-3-4-… (стек TLS у Chrome здесь не зависит от версии), characterize == Matches у обоих, 0 сирот. Нога QUIC теперь так же глубока, как C10 (TLS): структура плюс отдельный QUIC-JA3 (его 3 шифра TLS 1.3 и transport_parameters отличаются от 14-шифрового JA3 у TCP-TLS в C10) |
| C12 | Шум аудио | v1 (патч 0022): утверждает СТАБИЛЬНОСТЬ (НЕглубокая, как C7) — рендер OfflineAudioContext (фиксированный граф: треугольный осциллятор → DynamicsCompressor), хэшированный ДВАЖДЫ из одного и того же замороженного буфера getChannelData(0); стабильное двойное чтение доказывает детерминированное обратное чтение (сток или стабильный шум по семени в пределах ±1e-5), а НЕСОВПАДЕНИЕ — признак случайности на каждый вызов. Неглубокая (стабильность держится и на стоке); наличие шума и чувствительность к семени теперь ДОКАЗАНЫ вне набора тестом на два семени под гейтом built_core_canvas_and_audio_noise_are_seed_sensitive (семя аудио = canvas.seed в области "audio", так что два семени → разные хэши аудио) — ISSUE-026 (c) ЗАКРЫТА. B75 (2026-09-05): фикстура теперь несёт media.audioContext: "coherent-noise". С 0027 (2026-08-30) именно ЭТО поле, а не canvas.mode, включает шум аудио — а замороженная фикстура так и не согласилась на него, так что шесть дней эта строка хэшировала СТОКОВОЕ аудио (d089798d, байт в байт как у установленной 152) и называла его стабильным, пока тест на два семени был красным и его никто не гонял (#[ignore]). Проверка стабильности проходит и при ВЫКЛЮЧЕННОМ шуме; разницу говорит только тест на семя, ради чего он и существует. С переключателем: afd91acd на собранном ядре (сток afd49768), стабильно ×3, а тест на семя — сегодня он называется built_core_canvas_audio_and_webgpu_noise_are_seed_sensitive — зелёный на canvas, аудио и WebGPU. |
| C12b | Диапазон дБ у анализатора аудио (измерение) | ЗАПИСАТЬ минимум AnalyserNode.getFloatFrequencyData (площадка B) — слой проб, не блокирует, только характеризует; стережёт класс регрессий с зажимом в [-1,1], которого не видит офлайновый хэш C12 (у настоящего спектра в дБ минимум < -1; зажатый схлопывается в -1). Не зависит от времени (молчащий анализатор опускается очень низко). Поймал и теперь стережёт найденную ревью ошибку зажима float-freq (патч 0022 / ISSUE-026) |
| C15 | Голоса ⇄ locale.languages |
v1 (B71, 2026-09-03, путь B / ADR-0031): НЕглубокая мера согласованности (как C7/C12; НЕ входит ни в один deep_checks) — может ли хост ГОВОРИТЬ на том, что заявляет профиль? ПАДЕНИЕ, если ни один голос localService не несёт точный тег navigator.languages[0] (вторая фраза ISSUE-063, превращённая в проверку), ПАДЕНИЕ, если default-голос движка из другого языкового СЕМЕЙСТВА (B61: speak() без голоса попадает на голос по умолчанию процесса браузера, и тайминг читает этот выбор обратно — никакой фильтр списка его не спрячет); дрейф региона у голоса по умолчанию ЗАПИСЫВАЕТСЯ, а не роняет. Два нуля держатся порознь (empty-after-wait = НЕИЗВЕСТНО, проход; не собрано = ПАДЕНИЕ). Положительный контроль: заявление fr-FR на этом хосте → красный; en-US после Language.Speech~~~en-US → зелёный (локальный точный да, по умолчанию George en-GB того же семейства, точный нет); en-GB → зелёный. Измерено B71: с пакетом откат en-US попадает на Microsoft David (Δ1–6 ms, 4 из 4 прогонов, с личностью или без); до пакета он каждый раз попадал на George (B61). Инструмент: tests/harness/voices.mjs. |
3. Проверки на артефакты (никакой подписи шима)
| ID | Проверка |
|---|---|
| A1 | Function.prototype.toString у пропатченных API по-прежнему возвращает нативный код (никакой потери [native code]) — проверяется по всей пропатченной поверхности: геттеры navigator.* (0010/0012/0013), геттеры screen.* (0011), getParameter/getExtension у WebGL (0020), toDataURL/toBlob/getImageData у canvas (0021); нативный патч (на уровне движка) оставляет нативным каждый из них, а шим на JS/CDP хотя бы у одного это переворачивает |
| A2 | Никаких неожиданных собственных свойств и геттеров на прототипах navigator, screen, WebGLRenderingContext |
| A3 | Никаких аномалий по времени между вызовами подменённых API (на уровне движка, а не задержкой в JS) |
| A4 | Object.getOwnPropertyDescriptor на подменённых свойствах выглядит нативно |
| A6 | Согласованность разрешения на уведомления (GAP-permissions) — канонический признак headless-Chrome: Notification.permission и navigator.permissions.query({name:'notifications'}).state РАСХОДЯТСЯ (классически denied против prompt) там, где настоящий браузер держит их согласованными. A6 отображает Notification.permission в словарь PermissionStatus.state (default→prompt, иначе тождественно) и ПАДАЕТ на несовпадении; падает как пропуск на неизмеренной пробе (как A1/A2/A4/A5). Проверено живьём: сток M150 и собранный M149 оба сообщают Notification.permission 'default' ⇄ permissions.query 'prompt' (coherent) — современный --headless=new починил старую headless-несогласованность → A6 проходит, characterize == Matches. Стережёт регрессию (переключение режима headless или ребейз, возвращающие расхождение → A6 падает, набор ловит) |
| A5 | Никаких признаков автоматизации (GAP-webdriver) — запущенный профиль НЕ выдаёт, что это ядро под управлением CDP: navigator.webdriver равен false (Chrome ставит true под --enable-automation и под старым --headless; запуск использует --headless=new и сырой CDP, которые этого НЕ делают) И нет следов внедрённого драйвера (глобалы ChromeDriver $cdc_*/cdc_*, крючки __webdriver_*/__selenium_*/phantom на window/document). Падает как пропуск на неизмеренной пробе (как A1/A2/A4), чтобы офлайновый Default не выдал себя за проверенный; в остальном падает только на положительном признаке. Проверено живьём: сток M150 и собранный M149 оба сообщают navigator.webdriver false + no CDP/driver residue → A5 проходит, characterize == Matches. Стережёт регрессию (будущее переключение --enable-automation или поведения headless → A5 падает, набор ловит): проект отгружает движок автоматизации на CDP из Phase-5, так что неспрятанный флаг webdriver тривиально пометил бы ботом каждый автоматический прогон, независимо от подмены личности |
| A7 | Форма mediaDevices (GAP-media-devices) — ИЗМЕРЕНИЕ: перечислить navigator.mediaDevices.enumerateDevices() и ЗАПИСАТЬ форму (число устройств по видам, наличие меток, число различных групп). ПАДАЕТ только если API отсутствует (у настоящего Chrome он есть всегда) или проба не выполнилась. Счёт 0 устройств — классический признак бота в контексте С ОКНОМ, но ОЖИДАЕМ без окна (железа нет), поэтому он записывается и виден, но не под гейтом (гейт на 0 устройств в продакшене с окном и половина с ПОДМЕНОЙ в Blink отложены — как у ISSUE-020/023 значения возможностей и метрик заблокированы отсутствием набора данных). Проверено живьём: сток M150 и собранный M149 оба перечисляют 2 device(s): audiooutput=1 videoinput=1 (без audioinput, без групп, метки пусты) — --headless=new даёт фантомные устройства (а НЕ признак «0 устройств»), записано и видно; A7 проходит, characterize == Matches. Записывается, а не сверяется на равенство (как C2b/C3b) |
| A8 | Форма адаптера WebGPU (GAP-webgpu) — ИЗМЕРЕНИЕ, которое вскрыло НАСТОЯЩИЙ остаток: патч C2 (0020) подменяет строки вендора и рендерера у WebGL, но adapter.info и adapter.limits у WebGPU отражают настоящий GPU хоста без изменений — межинтерфейсная несогласованность, которую детектор перекрёстно проверяет. A8 ЗАПИСЫВАЕТ личность адаптера (вендор, архитектура, описание — часто замаскированные самим Chrome) плюс приближение возможностей (maxTextureDimension2D, число фич); падает как пропуск только на несобранной пробе, иначе ПРОХОДИТ (видимость, не гейт). Проверено живьём: сток M150 и собранный M149 оба сообщают WebGPU ok: adapter 'nvidia/pascal' maxTex2D=16384 features=18 — настоящий GPU этой машины разработчика на Windows. На СТОКЕ WebGL не подменён, поэтому WebGL и WebGPU СОГЛАСНЫ (утечки нет); на СОБРАННОМ ядре C2 подменяет WebGL на Apple-M3, а WebGPU по-прежнему выдаёт nvidia/pascal — живая межинтерфейсная несогласованность GPU, теперь записанная и видимая. ПОДМЕНА (согласованная проекция WebGPU) ОТЛОЖЕНА под ISSUE-020 (заблокирована набором данных — синтезированное тело возможностей WebGPU для Apple-M3 само было бы признаком нереалистичности, то же ограничение, что и у тела возможностей WebGL). A8 проходит, characterize == Matches |
| A9 | navigator.plugins несёт фиксированный набор PDF от Chrome (GAP-plugins-mime) — современный Chrome отгружает ФИКСИРОВАННЫЙ набор из 5 записей PDF-плагина + pdfViewerEnabled === true + mime-типы PDF (настоящие по построению, не подменённые). ПУСТОЙ navigator.plugins — классический признак headless-бота → A9 на нём ПАДАЕТ (и падает как пропуск на неизмеренном Default, как A1/A2/A4/A5/A6); в остальном утверждает ту согласованность, которую держит настоящий Chrome (плагины есть ⇒ pdfViewerEnabled и ненулевые mime-типы). Проверено живьём: сток M150 и собранный M149 оба сообщают 5 plugin(s), pdfViewerEnabled=true, mimeTypes=2 — --headless=new несёт полный набор PDF (а НЕ старый headless-признак «0 плагинов»), A9 проходит, characterize == Matches. Страж регрессии только для характеризации (ребейз или переключение headless, опустошающие набор, падают громко) |
| A10 | Форма списка голосов (GAP-speech-voices) — ИЗМЕРЕНИЕ: speechSynthesis.getVoices() — это список, коррелирующий с ОС (например, Microsoft * на Windows, Alex/Samantha на macOS), того же класса, что утечка шрифтов (C3): профиль, подделанный под macOS, на хосте с Windows выдаёт голоса Windows. A10 ЗАПИСЫВАЕТ форму (число, образец имён, различные языки); падает как пропуск только на несобранной пробе, иначе ПРОХОДИТ. Проверено живьём: сток M150 и собранный M149 оба сообщают 0 voice(s) — у --headless=new нет движка TTS, так что в наборе без окна голоса ОС не утекают (прогон в продакшене С ОКНОМ показал бы голоса хоста — записано и видно для того гейта). Счёт 0 голосов записывается, а не под гейтом (как 0 устройств без окна у A7); ПОДМЕНА — это патч Blink на потом. A10 проходит, characterize == Matches |
Вот почему патч на уровне движка бьёт внедрение JS (принцип Camoufox). Собранное патченое ядро должно проходить A1–A4 по построению; провайдер на JS/CDP — не будет, и это его задокументированный потолок.
4. Внешние пробы (с оценкой)
Прогнать без окна (Playwright/CDP) против ядра, снять и оценить:
- CreepJS — оценка доверия и выявление лжи (помечает несогласованности и перекрытия).
- pixelscan — согласованность и флаги автоматизации.
- fingerprint.com / в духе BotD — вероятность бота.
- browserleaks (WebRTC, canvas, шрифты, WebGL) — утечка по каждому сигналу. Каждая проба → нормализованная подоценка; сырой вывод сохраняется артефактом, чтобы сравнивать между версиями.
5. Карта и гейт
suite result = {
coherence: all C1..C9 PASS (any fail → BLOCK)
artifacts: all A1..A4 PASS (any fail → BLOCK)
probes: weighted score ≥ THRESHOLD (else BLOCK)
trust_score: provenance × validator × core_capability
}
- Любое падение согласованности или артефактов блокирует релиз, какой бы ни была оценка проб.
- THRESHOLD для проб — настраиваемая величина (ОТКРЫТЫЙ вопрос): начинать строго, калибровать по заведомо хорошему ядру.
- Вывод машиночитаем; CI держит последнее заведомо хорошее ядро закреплённым, пока новая сборка не пройдёт.
Набор глубоких возможностей (якорь базовой линии). Провайдер объявляет, какие глубокие проекционные проверки он на самом деле подделывает, как набор идентификаторов проверок (
deep_checks, например[]для стока,["C8"]для сборки 0010) — на замену прежнему одиночному булевуdeep_fingerprint. Тогда характеризация базовой линии ожидает, что каждая глубокая проверка из набора ПРОЙДЁТ, а любая другая глубокая — УПАДЁТ (задокументированный потолок: этот сигнал ещё не пропатчен). Поскольку патчи личности приземляются постепенно (0010 navigator-basics →C8; WebGL и шрифты →C2/C3позже), набор позволяет правильно охарактеризовать частично пропатченное ядро вместо «всё или ничего» (ISSUE-019). Глубокая проверка, которая проходит вне набора (совпадение с хостом) или падает внутри него (регрессия), — это ОТКЛОНЕНИЕ.
⚠ Оговорка об области C2 (остаток профиля возможностей WebGL, ISSUE-020). C2 сегодня читает только две строки
WEBGL_debug_renderer_info(UNMASKED_VENDOR/RENDERER); патч 0020 подменяет ровно их. Так что зелёный C2 НЕ означает «WebGL согласован»: числовые пределыgetParameter, список и порядокgetSupportedExtensions, матрицаgetShaderPrecisionFormat,getContextAttributes, параметры только для WebGL2,GPUAdapterу WebGPU и readPixels с хэшем пикселей остаются от бэкенда ANGLE хоста (настоящий D3D11 на машине разработчика) и могут противоречить подменённой строке GPU. Теперь C2b это ИЗМЕРЯЕТ — проверка слоя проб записывает профиль возможностей (MAX_TEXTURE_SIZE+MAX_*_UNIFORM_VECTORS+MAX_RENDERBUFFER_SIZE+ дайджест отсортированных расширений + точность highp-float) в каждую карту, так что остаток ВИДЕН рядом с подменённой строкой, а не спрятан. C2b ПРОХОДИТ на правдоподобном профиле и ПАДАЕТ только на НЕДОСТУПНОМ или ГРУБО СЛОМАННОМ контексте (пустые расширения илиMAX_TEXTURE_SIZE < 4096); она НЕ обнаруживает программный откат на SwiftShader — настоящий SwiftShader сообщает правдоподобные возможности (16384 и полный набор расширений), а c2b идёт после подмены, так что этому признаку программного рендерера нужен различитель по возможностям (патч на потом). Взаимодействие с гейтом: слой проб никогда не блокируетVerdict, но C2b неглубокая, поэтому её ПАДЕНИЕ является отклонениемcharacterize(сломанный контекст всплывает, а не молча зеленеет). C2b не судит о том, соответствуют ли возможности GPU из конфигурации (для этого нужен эталонный набор данных на каждый GPU); половину с возможностями закрывает патч параметров и расширений (≈0024 — 0021/0022/0023 заняты canvas, аудио и шрифтами).
6. Ожидания по каждому провайдеру
| Провайдер | Ожидаемый результат набора |
|---|---|
chromium-stock (CDP/внедрение) |
проверки C в основном проходят; A1–A4 скорее всего ПАДАЮТ → низкое доверие, задокументированный потолок, не для высоких ставок |
camoufox/патченая база |
проверки A проходят (на уровне движка); хорошие оценки проб |
chromium-patched (наш) |
все C и A проходят; пробы ≥ порога → отгружаем |
Измеренные базовые линии собранного ядра, по пину. M149 (
149.0.7827.201, 2026-06-27 → 2026-09-04): первозданное =Matchesна стоковом потолке (пустойdeep_checks), пропатченное =Matchesсdeep_checks = [C8, C6, C1, C2, C4, C3, C9]. M152 (152.0.7977.75,B74, 2026-09-04/05): первозданная сборка (out/M152,chrome.dll 2026-09-04 14:24:54), прочитанная через стоковое объявление, — этоMatchesна потолке: все семь проекционных проверок падают, как на стоке, каждая неглубокая проходит,Chrome/152.0.7977.75, — а её JA4 (t13i1516h2_002f,…,cca9_0005,…,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601), Akamai (1:65536;2:0;4:6291456;6:262144|15663105|m,a,s,p) и QUIC-JA3 байт в байт совпадают с установленным стоковым 152.0.7977.75, измеренным тем же инструментом (JA3 отличается только перетасовкой расширений на каждое соединение у Chrome). Пропатченная сборка (chrome.dll 2026-09-05 02:24:38, серия из 21 на базе 152) — этоMatches, 7 глубоких,Pass, все 32 проверки с теми же pass/deep, что и на M149, C15 в карте, и те же JA4/Akamai, что у первозданной: сетевая инверсия (ADR-0027) держится на 152 по построению. Переход 149→152 оценён вSERIES_REBASE_COST.md(internal:docs/30-core/SERIES_REBASE_COST.md) §7. Стоковый потолок M152 отличается от потолка M149 двумя неглубокими фактами, которые стоит знать: ClientHello теперь несёт алгоритмы подписи ML-DSA (0904,0905,0906) и расширение Trust Anchor IDs (ca34) в коде, а не по полевому испытанию (B65измерялca34как отличие только стока на 149).
7. Как это запускается
- Локально:
core/scripts/run-consistencyпротив собранного ядра → карта. - В CI: гейт на каждую сборку ядра (и на изменения каталога отпечатков) до публикации.
- Перед запуском (во время работы): быстрое подмножество (C1–C5) проверяет личность профиля до
profile.start; падения помечают запуск низким доверием (FINGERPRINT_CONFIG_CONTRACT §6) и никогда не отгружаются молча.
8. Тихий режим — пробы с окном, но без окон (B68)
run-consistency окна не показывает никогда: оценивающий набор по замыслу без окна (§7). Сотнями на рабочем
столе оператора появляются запуски с окном — обвязка интеграционных тестов (probe_fonts*,
screen_metrics, headers_ua_ch, engine_baseline, …) и самохарактеризация с окном
(capture_suite(…, headless = false)). Все девять таких мест запуска идут через одного помощника,
vflin_consistency::quiet::launch_headed, и именно там живёт тихий режим.
Переключатель: включён по умолчанию на Windows с B70 (2026-09-03); VFLIN_SUITE_QUIET=0 выключает
его, =1 по-прежнему принимается; не на Windows: всегда выключен (охрана фокуса есть только на Windows,
функция перекрытия — это виндовый трекер Chrome, а окно за пределами экрана на X11/Wayland никогда не
измерялось — CI на ubuntu запускает как прежде). Он стал умолчанием, потому что B69 измерил: без него
обвязка теряет — обе стороны ISSUE-148 суть условия видимости страницы, которые вызывает скрытое или
перекрытое окно обвязки, а тихий режим держит страницу visible по построению — 160/160 на выборке B67
против 158/160 и 159/160. Путь запуска продукта (launch.rs) ничего из этого не видит: собственные профили
оператора открываются на экране ровно как раньше.
Что он делает, и каждую часть вынудило измерение (worklog/core.md §B68):
| часть | зачем | измерено |
|---|---|---|
--window-position=-32000,-32000 |
создать окно настоящего размера за пределами всех дисплеев | учтено, не зажато: страница читает screenX -32000, при первом взгляде оно уже за экраном |
--disable-features=CalculateNativeWinOcclusion (влито в существующий --disable-features обвязки) |
окно вне дисплеев получает visibilityState: hidden, и rAF останавливается — это потеря |
с ним: visible, 10 кадров rAF за ~140 ms, как и на экране |
| возвращать окно за экран всякий раз, когда Chrome кладёт его на дисплей | провайдер дописывает --window-position=0,0 (ISSUE-062) после extra_args; Chrome держится последнего значения |
крючок WinEvent плюс опрос каждые 20 ms; каждый возврат посчитан |
возвращать фокус (AttachThreadInput + SetForegroundWindow, подтверждено чтением переднего плана) |
Chrome активирует своё окно при создании, за экраном или нет | взят на 458 из 460 запусков, возвращён на 415, медиана ~65 ms; в 5 из 20 прогонов отказывали пачками (бюджет 1.5 s). Глазами оператора: передний план занят окном обвязки ~1 % времени против ~30 % в обычном режиме |
Чего он менять не должен и не меняет: размер окна (outerWidth/outerHeight = заявленная доступная
область, ISSUE-062 — размер задаёт провайдер, тихий режим только позиционирует), visibilityState, ритм rAF
и все проверки. Измерено на собранном ядре M149: 20 из 20 тихих прогонов инструмента C6 байт в байт
совпали с обычной базовой линией по каждой строке screen/avail/outer/inner; весь объект Signals с окном
различается между режимами не больше собственного шумового порога (observed_ja3); run-consistency — 31 из
31 проверки одинаковы, 7 глубоких, Pass в обоих режимах.
Чего он скрыть до конца не может, сказано как измерено: вспышка примерно на 41 % запусков (от одного
до трёх кадров: окно создаётся провайдером в 0,0 и переносится при первом взгляде — создавать его сразу за
экраном значит изменить innerWidth на 16 px, и это отвергнуто) и отказы в фокусе пачками (в 5 из 20
прогонов передний план удерживается до 1.5 s на нескольких запусках). В терминах выборки: окно обвязки видно
примерно в 5 % снимков (в обычном режиме ~32 %).
Каждый тихий запуск печатает одну строку [quiet] …, называющую, что произошло (перенесено до или после
показа, сколько возвратов, фокус взят и возвращён или не брался вовсе), так что серию можно проверить по её
журналу. Неудача запуска по-прежнему отвечает именами из B67 (launch-port-timeout, cdp-connect-refused,
…) — тихий режим не добавляет новых видов отказа.
Открытые вопросы
Стабильность источников проб и их условия использования, точный THRESHOLD, обвязка без окна (Playwright или сырой CDP), как гонять пробы против ещё не опубликованного ядра, формула оценки доверия → OPEN_QUESTIONS.md.