ЭЦП в Safari на macOS: как я подружил Госплагин, Контур.Плагин и КриптоПро с Safari Web Extensions
- пятница, 23 октября 2026 г. в 00:00:06
Если у вас 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 из белого списка;
содержимое запросов и подписей не логируется.
С КриптоПро пришлось повозиться больше всего. Официальное расширение (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 восстанавливает вкладки раньше, чем просыпается фон расширения, и 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.