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

Если у вас 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.