javascript

ЭЦП в Safari на macOS: как я подружил Госплагин, Контур.Плагин и КриптоПро с Safari Web Extensions

  • пятница, 23 октября 2026 г. в 00:00:06
https://habr.com/ru/articles/1097800/

Если у вас Mac и электронная подпись на Рутокене, вы наверняка знаете ритуал: чтобы войти на Госуслуги по ЭЦП, отчитаться в личном кабинете ФНС или подписать документ в Диадоке, нужно открыть Chrome. Госплагин, Контур.Плагин и КриптоПро ЭЦП Browser plug‑in официально работают только в Chromium‑браузерах и Firefox. У КриптоПро есть старое Safari App Extension, но в актуальных версиях Safari оно даже не появляется в настройках.

Я захотел, чтобы всё это работало в Safari, и в итоге получилось три небольших приложения для macOS с открытым кодом: Gosplugin Safari (Госуслуги/ЕСИА), Kontur Safari (Диадок и сервисы Контура) и CryptoPro Safari (nalog.ru, торговые площадки и всё, что работает через cadesplugin). Ниже — как это устроено и на какие грабли Safari я наступил по дороге.

Почему нельзя просто сконвертировать расширение

У Apple есть xcrun safari-web-extension-converter, который превращает Chrome‑расширение в Xcode‑проект. Расширение из CRX конвертируется за минуту, но работать не будет:

  • Нет Chrome Native Messaging. В Safari runtime.connectNative и sendNativeMessage ведут не к произвольному native host из NativeMessagingHosts/*.json, а только к модулю вашего же приложения (SafariWebExtensionHandler), который живёт в песочнице.

  • Нет localhost. У Госплагина есть запасной режим через WebSocket на 127.0.0.1, но Safari не пускает расширения на localhost.

  • Нет части API. Контур.Плагин проверяет chrome.management, которого в Safari нет, и без патча молча отбрасывает все запросы сайта.

  • Строгий порядок вызовов. Safari выполняет обращения расширения к native‑модулю строго по одному — это сильно влияет на производительность (об этом ниже).

Архитектура

Сайт ──► Safari-расширение (официальное, с патчем)
           │ browser.runtime.sendNativeMessage
           ▼
         модуль расширения (SafariWebExtensionHandler, в песочнице)
           │ HTTP 127.0.0.1:9105 / 9106 / 9107
           ▼
         мост внутри приложения (фоновый процесс <app> --bridge)
           │ stdio, протокол Chrome Native Messaging
           ▼
         Госплагин / Контур.Плагин / nmcades ──► КриптоПро CSP ──► токен

Модулю расширения в песочнице разрешены исходящие соединения (network.client + NSAllowsLocalNetworking), а вот запускать чужие бинарники — нет. Поэтому плагин запускает само приложение: при первом старте оно ставит себя в LaunchAgent с аргументом --bridge и дальше работает без окна, слушая 127.0.0.1. Мост находит официальный манифест native host в /Library/Google/Chrome/NativeMessagingHosts, запускает указанный там бинарник с allowed_origins[0] в аргументах (как это делает Chrome) и общается с ним обычным протоколом: 4 байта длины little‑endian + JSON.

Само расширение — официальное, скачанное из Chrome Web Store, с минимальными патчами: chrome.runtime.connectNative(...) заменён на свою реализацию порта поверх sendNativeMessage.

Как уложить стриминговый порт в запрос‑ответ

Native Messaging в Chrome — это долгоживущий порт: сообщения ходят в обе стороны когда угодно. В Safari есть только «отправил — получил ответ», да ещё и последовательно. Первая версия делала open/send/poll, где poll ждал ответ до 2 секунд, — и Диадок стал заметно тормозить: пока одна вкладка висит в poll, запросы остальных стоят в очереди Safari.

Что в итоге сработало:

  • send сразу ждёт ответа плагина (до 8 с) и возвращает его в том же вызове — одна операция вместо двух;

  • poll запускается только если есть запрос без ответа (например, пользователь вводит PIN), и короткими интервалами по 0,5 с;

  • ответы, приходящие частями (у Контура — isChunked/isFinalChunk, у Госплагина — meta.data.chunk/chunks, у КриптоПро — partial), учитываются, чтобы не прекращать опрос раньше времени.

Безопасность моста

Локальный HTTP‑сервер, который умеет подписывать документы, — очевидная мишень для любого сайта. Защита простая и надёжная:

  • мост слушает только 127.0.0.1;

  • принимает только POST с нестандартным заголовком X-NMA: 1 и без заголовка Origin. Страница в браузере не может отправить такой запрос: нестандартный заголовок требует CORS‑preflight, который мост отклоняет, а браузер всегда добавляет Origin. Модуль расширения ходит через URLSession и Origin не ставит;

  • каждое приложение запускает только свой native host из белого списка;

  • содержимое запросов и подписей не логируется.

КриптоПро и nalog.ru: самое интересное

С КриптоПро пришлось повозиться больше всего. Официальное расширение (MV3) в content.js для Safari использует старый API Safari App Extension (safari.extension.dispatchMessage), которого у Web Extension нет, — пришлось перевести его на обычный runtime.connect.

Но главное — в cadesplugin_api.js, который сайты (включая nalog.ru) подключают к себе. Для Safari он ждёт от расширения адрес скрипта и проверяет его так:

if (!url.match("^(moz|safari)-extension://[a-zA-Z0-9/_-]+/nmcades_plugin_api.js$")) {
    plugin_loaded_error();
    return;
}

У Safari Web Extension схема safari-web-extension://, и проверку она не проходит. Менять код сайтов нельзя, поэтому расширение отдаёт странице условный адрес safari-extension://nma-cryptopro/nmcades_plugin_api.js, а маленький скрипт в контексте страницы подменяет его на настоящий в момент, когда cadesplugin_api.js создаёт тег <script>:

var FAKE = /^safari-extension:\/\/nma-cryptopro\/nmcades_plugin_api\.js$/;
var sa = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (n, v) {
    if (this instanceof HTMLScriptElement && String(n).toLowerCase() === "src" && FAKE.test(v)) v = realUrl;
    return sa.call(this, n, v);
};

После этого вход и подпись на nalog.ru заработали в Safari.

Грабли Safari, которые не найти в документации

  • Восстановленные вкладки. После перезагрузки Safari восстанавливает вкладки раньше, чем просыпается фон расширения, и tabs.sendMessage к ним падает с «Tab not found». Решение — в таком случае рассылать ответ во все вкладки: контент‑скрипт сам отбрасывает чужие сообщения по id.

  • Потерянная связь с вкладкой. Иногда tabs.sendMessage не падает, но сообщение до вкладки не доходит — и это не лечится даже перезагрузкой страницы, только новой вкладкой. Контент‑скрипт Госплагина отвечает на каждое сообщение sendResponse(null), поэтому теперь фон ждёт подтверждение 1,5 с и без него делает ту же рассылку.

  • Мост после входа в систему. LaunchAgent стартует не мгновенно, а Safari уже открывает вкладки — расширение повторяет подключение к мосту до 60 секунд.

  • Обновление приложения при открытом Safari. Если заменить.app, пока Safari запущен, он решает, что расширение удалено, и выключает его. Обновлять — только закрыв Safari.

  • Утечка процессов. Расширения открывают порты на каждую вкладку, а Safari не всегда их закрывает — мост закрывает сессии, к которым никто не обращался больше 2 минут.

Подпись приложения без платного аккаунта

Safari спокойно загружает расширения из приложений, подписанных сертификатом Apple Development, а его можно получить и с бесплатным Apple ID через Xcode. Поэтому релиз собирается без подписи (ad‑hoc, universal), а пользователь один раз подписывает его своим сертификатом:

codesign --force --sign "Apple Development" --preserve-metadata=entitlements --options runtime "Gosplugin Safari.app"/Contents/PlugIns/*.appex
codesign --force --sign "Apple Development" --preserve-metadata=entitlements --options runtime "Gosplugin Safari.app"

Без подписи тоже работает, но через «Разработка → Разрешить неподписанные расширения», и эту галочку Safari сбрасывает при каждом перезапуске.

Итог

Три приложения, официальные плагины и расширения, ничего не уходит с компьютера. Нужны macOS 12+, Safari 16+, КриптоПро CSP с лицензией и соответствующий плагин; Chrome не нужен. Проект не связан с Ростелекомом, СКБ Контур или КриптоПро.

Код, DMG и подробная инструкция: github.com/KLon777/gosuslugi‑diadoc‑safari‑ecp. Буду рад issue и PR — особенно если у вас другой носитель или другой сайт с cadesplugin.