# Виртуальный плагин «Ключ Онлайн»

Библиотека, которая для сайта выглядит как браузерный плагин КриптоПро, но выполняет подпись
на другом компьютере — на том, где физически вставлен токен.

Сайту не нужно ничего переписывать: он продолжает работать с `window.cadesplugin`.
Меняется одна строка — подключение скрипта.

---

## Зачем это нужно

Обычная схема требует, чтобы у каждого подписанта на его собственном компьютере стояли
КриптоПро CSP, браузерный плагин и был вставлен токен. Это и есть главная причина, по которой
подпись «не работает»: не тот браузер, не установлен плагин, токен у другого сотрудника,
человек в отпуске.

«Ключ Онлайн» разрывает эту связку. Токен остаётся на одном компьютере — например, в бухгалтерии.
Подписывать можно с любого другого устройства и из любого браузера: команда уходит на сервер,
сервер передаёт её программе на компьютере с токеном, оттуда возвращается готовая подпись.

---

## Подключение: 3 шага

### Шаг 1. Получите доступы

В кабинете «Ключ Онлайн» вам выдадут:

| Что | Зачем |
|---|---|
| `server` | адрес сервера, например `https://sign.sautiapp.ru` |
| `siteKey` | ключ вашего сайта, уходит в заголовке `X-Site-Key` |
| `deviceRef` | какой компьютер с токеном обслуживает ваши запросы |

Домен вашего сайта нужно назвать заранее — сервер отвечает только разрешённым источникам (CORS).

### Шаг 2. Подключите библиотеку

Вместо скрипта плагина КриптоПро подключите наш:

```html
<script>
  window.KeyOnline = {
    server:    'https://sign.sautiapp.ru',
    siteKey:   'ваш-ключ-сайта',
    deviceRef: 'идентификатор-устройства'
  };
</script>
<script src="https://sign.sautiapp.ru/keyonline-plugin.js"></script>
```

Либо короче, через атрибуты самого тега:

```html
<script src="https://sign.sautiapp.ru/keyonline-plugin.js"
        data-server="https://sign.sautiapp.ru"
        data-site-key="ваш-ключ-сайта"
        data-device-ref="идентификатор-устройства"></script>
```

### Шаг 3. Ничего больше не меняйте

Код подписи остаётся прежним.

---

## Было → стало

**Было** (сайт под плагин КриптоПро):

```html
<script src="/js/cadesplugin_api.js"></script>
```

**Стало:**

```html
<script src="https://sign.sautiapp.ru/keyonline-plugin.js"
        data-server="https://sign.sautiapp.ru"
        data-site-key="ваш-ключ-сайта"
        data-device-ref="идентификатор-устройства"></script>
```

Весь остальной код сайта не трогается. Например, этот фрагмент работает без единой правки
и до, и после замены:

```js
await cadesplugin;

const store = await cadesplugin.CreateObjectAsync('CAdESCOM.Store');
await store.Open(
  cadesplugin.CAPICOM_CURRENT_USER_STORE,
  cadesplugin.CAPICOM_MY_STORE,
  cadesplugin.CAPICOM_STORE_OPEN_MAXIMUM_ALLOWED
);

const certs = await store.Certificates;
const count = await certs.Count;          // нумерация с ЕДИНИЦЫ
const cert  = await certs.Item(1);
const name  = await cert.SubjectName;     // свойства асинхронные — как в настоящем плагине

const signer = await cadesplugin.CreateObjectAsync('CAdESCOM.CPSigner');
await signer.propset_Certificate(cert);
await signer.propset_Options(cadesplugin.CAPICOM_CERTIFICATE_INCLUDE_WHOLE_CHAIN);

const signedData = await cadesplugin.CreateObjectAsync('CAdESCOM.CadesSignedData');
await signedData.propset_ContentEncoding(cadesplugin.CADESCOM_BASE64_TO_BINARY);
await signedData.propset_Content(contentBase64);

const signature = await signedData.SignCades(signer, cadesplugin.CADESCOM_CADES_BES, false);

await store.Close();
```

---

## Что поддержано

### Сам объект `cadesplugin`

| Вызов | Поведение |
|---|---|
| `await cadesplugin` / `.then(...)` | готовность плагина; резолвится пустым значением, как настоящий |
| `CreateObjectAsync(имя)` | создание объектов из таблицы ниже |
| `async_spawn(function*(args){…}, …)` | прогон генератора с разворачиванием `yield`-промисов; повторяет оригинал (генератор получает один аргумент-массив, результат возвращается как есть) |
| константы `CADESCOM_*`, `CAPICOM_*`, `CERT_TRUST_*` | привычный набор |
| `getLastError(e)` | текст последней ошибки |
| `set_log_level(...)`, `current_log_level`, `LOG_LEVEL_*` | уровень запоминается; на `LOG_LEVEL_DEBUG` включается наш вывод в консоль |
| `get_extension_version(cb)`, `get_extension_id(cb)` | через callback, как в оригинале; отдают нашу метку, а не идентификатор расширения КриптоПро |
| `is_capilite_enabled()` | всегда `false` — мы не CAPILite |
| событие `cadesplugin_loaded` | рассылается после загрузки |

Дополнительно наши: `cadesplugin.KeyOnline === true` (отличить нас от настоящего плагина),
`KeyOnlineInfo()` (текущие настройки), `KeyOnlineReset()` (сбросить кэш сертификатов).

### Объекты `CreateObjectAsync`

| Объект | Что поддержано |
|---|---|
| `CAdESCOM.About` | `Version`, `PluginVersion`, `MajorVersion`, `MinorVersion`, `BuildVersion`, `CSPName()`, `CSPVersion()` |
| `CAdESCOM.Store` (он же `CAPICOM.Store`) | `Open(размещение, имя, режим)` — параметры принимаются, хранилище у нас одно (токен); просьба про чужое хранилище или про запись даёт предупреждение в консоли. Ещё `Certificates`, `Close()`, `Location`, `Name` |
| — `Certificates` | `Count`, `Item(i)` — **нумерация с единицы**; `Find(вид, критерий, [толькоДействующие])` |
| — `Certificates.Find` | по отпечатку (`SHA1_HASH`), владельцу (`SUBJECT_NAME`), издателю (`ISSUER_NAME`), расширению по OID (`EXTENSION`), сроку действия (`TIME_VALID`, `TIME_NOT_YET_VALID`, `TIME_EXPIRED`) и по расширенному свойству `EXTENDED_PROPERTY` со значением `2` (`CAPICOM_PROPID_KEY_PROV_INFO` — «есть закрытый ключ»). Возвращает коллекцию; «ничего не нашлось» — это `Count = 0`, а не ошибка |
| — сертификат | `Thumbprint`, `SubjectName`, `IssuerName` (полные строки имён), `ValidFromDate`, `ValidToDate`, `SerialNumber`, `Version`, `HasPrivateKey()`, `IsValid()`, `Export(0)`, `PublicKey()`, `PrivateKey`, `Extensions()` |
| — открытый ключ | `Algorithm` (`Value` — OID, `FriendlyName` — название), `EncodedKey`, `EncodedParameters`, `Length`. Считается разбором самого сертификата (`certBase64`) |
| — закрытый ключ | `ProviderName` — «Ключ Онлайн (удалённая подпись)». Имя контейнера пустое: ключ на другом компьютере. `propset_CachePin()` принимается с предупреждением (решение принимается там, где вводят PIN), `propset_KeyPin()` отказывает |
| — расширения | `Count`, `Item(i)` → `OID`, `IsCritical`, `EncodedData` |
| `CAdESCOM.CPSigner` | `propset_Certificate()`, `propset_Options()`, `propset_TSAAddress()`, `propset_CheckCertificate()`, `Certificate`, `Options`, `AuthenticatedAttributes2` |
| `CAdESCOM.CadesSignedData` | `propset_Content()`, `propset_ContentEncoding()`, `propset_DisplayData()`, `SignCades(signer, тип, detached)`, `SignHash(hashedData, signer, тип)` — открепленная подпись, как у настоящего плагина; `AdditionalStore()` — принимается и не используется |
| `CAdESCOM.RawSignature` | `SignHash(hashedData, cert)` — возвращает HEX-строку, как настоящий плагин |
| `CAdESCOM.CPHashedData` (он же `CAdESCOM.HashedData`) | `propset_Algorithm()`, `propset_DataEncoding()`, `Hash(данные)` — повторные вызовы **накапливают** данные, как в настоящем плагине (сайты режут большие файлы на куски) |
| `CAdESCOM.CPAttribute` | `propset_Name()`, `propset_Value()` — принимается, но не влияет на подпись |
| `CAdESCOM.CadesTools` | `PackageManager()` — отвечает пустой строкой: мы не Android-плагин. Объект «мягкий»: остальные его вызовы не роняют страницу (см. ниже) |

Все обращения асинхронные, включая чтение свойств: пишите `await cert.Thumbprint`,
как и с настоящим плагином.

**Даты срока действия** (`ValidFromDate`, `ValidToDate`) — строка ISO-8601 с `Z`,
например `2026-04-24T08:57:05.000Z`. Настоящий плагин тоже отдаёт их строкой (значение
проходит через JSON по дороге от расширения к странице), и все известные потребители
обращаются с ней одинаково — оборачивают в `new Date(...)`: так делает демо-страница
КриптоПро, так делает библиотека RusCryptoJS (её грузит ВЕТИС), так делает «Честный знак».
ISO с `Z` выбран потому, что это единственная запись, которую все браузеры разбирают
одинаково и которая сохраняет момент времени в UTC. Русская запись вида
`24.04.2026 8:57:05` в Chrome даёт `Invalid Date` — сайт молча решил бы, что срок
действия неизвестен, и сертификат «пропал» бы из списка. Что бы ни прислала программа
с компьютера с токеном, библиотека приводит дату к этому виду сама; неразобранное
значение отдаётся как есть с предупреждением в консоли — подставлять вместо него
выдуманную дату нельзя.

Обращение к неподдерживаемому свойству или объекту не возвращает `undefined`, а сразу
бросает понятную ошибку с названием вызова — чтобы несовместимость обнаруживалась
на первом же тесте, а не в виде испорченной подписи у клиента.

Исключение — объекты **необязательной диагностики**: `CadesTools`, открытый и закрытый ключ
сертификата (`PublicKey`, `PrivateKey`), идентификатор алгоритма (`OID`), закодированные данные
(`EncodedData`) и расширение сертификата.
У них неизвестный вызов не бросает исключение, а отдаёт пустое значение и один раз пишет
предупреждение в консоль. Граница проведена сознательно: проверка вроде «а не Android ли
это?» не должна обрывать весь сценарий подписи, а вот всё, что относится к самой подписи,
обязано падать честно.

---

## Ограничения — честно

Это не плагин КриптоПро, и разница видна в нескольких местах.

**Задержка.** Подпись занимает секунды, а не миллисекунды: запрос идёт на сервер, оттуда на
компьютер с токеном и обратно. Ожидание — 2–5 секунд, предел — 120 секунд (настраивается через
`window.KeyOnline.timeout`). Показывайте пользователю состояние «подписываем», иначе он
нажмёт кнопку второй раз.

**PIN-код вводится на удалённой машине.** Мы принципиально не передаём PIN через браузер:
`propset_KeyPin` всегда возвращает ошибку. Для подписи без участия человека PIN должен быть
заранее сохранён на компьютере с токеном.

**Подпись видна владельцу токена.** Каждая операция попадает в журнал устройства и в кабинет.
Это сделано намеренно: удалённая подпись отличается от кражи ключа только согласием и журналом.

**Не поддержано:**

| Вызов | Почему |
|---|---|
| `CreateObject` (синхронный) | за каждым вызовом стоит сеть, а синхронно дождаться её в браузере нечем. Заглушку не отдаём намеренно: синхронный код пишет `if (found.Count != 1) throw …`, и промис вместо числа увёл бы сайт в заведомо ложное «сертификат не найден». Практического вреда нет — синхронного плагина не существует со времён NPAPI, поэтому у сайтов эта ветка стоит под проверкой `if (CreateObjectAsync)` и при живом асинхронном интерфейсе не выполняется |
| `CPHashedData.Value` | ГОСТ-хеш нечем посчитать в браузере — его считает компьютер с токеном в момент подписи |
| `CPHashedData.SetHashValue` | для удалённой подписи нужны исходные данные, а не готовый хеш |
| `VerifyCades`, `VerifyHash`, `GetMsgType` | проверять и разбирать готовую подпись в браузере бессмысленно — делайте это на своём сервере |
| `Certificates.Find` по корню цепочки, шаблону, политикам и назначению ключа (`ROOT_NAME`, `TEMPLATE_NAME`, `APPLICATION_POLICY`, `CERTIFICATE_POLICY`, `KEY_USAGE`) | цепочку мы не строим, а остальное — свойства хранилища Windows, которых у нас нет. Отбор по отпечатку, именам, сроку, расширению и признаку «есть закрытый ключ» работает (см. таблицу выше) |
| `Certificates.Find(EXTENDED_PROPERTY, …)` с любым свойством кроме `2` | расширенные свойства хранилища остаются на компьютере с токеном |
| метка времени (TSA) | адрес принимается, но игнорируется |
| усиленные виды подписи (CAdES-T, CAdES-X Long Type 1) | вид подписи задаёт программа на компьютере с токеном; запрос усиленного вида даёт предупреждение в консоли |
| подписанные атрибуты (`AuthenticatedAttributes2`) | принимаются и игнорируются: время подписания проставляет криптопровайдер на удалённой машине |
| `Certificate.Export()`, `PublicKey()`, `Extensions()` | работают, только если сервер отдаёт тело сертификата (поле `certBase64`). Старые версии программы на компьютере с токеном его не присылают: `Export` тогда отказывает с внятным текстом, а ключ и расширения отвечают пустыми значениями с предупреждением в консоли |
| XML-подпись (`CAdESCOM.SignedXML`, `EnvelopedXML`) | не реализована. Константы адресов алгоритмов (`XmlDsigGost…`) при этом заданы: страницы читают их **до** создания объекта, чтобы выбрать алгоритм по OID ключа, и пустое значение увело бы сайт в ветку «алгоритм не поддерживается» — то есть он соврал бы про причину |
| `CAdESCOM.CPLicense`, `CPEnvelopedData`, `SymmetricAlgorithm`, `Certificate`, `X509Enrollment.*` | не реализованы: это выпуск запросов, шифрование и лицензии — не наш сценарий. Демо-страница КриптоПро вызывает их внутри своих `try/catch`, поэтому честная ошибка её не ломает |
| константы `CADESCOM_PRODUCT_*`, `XCN_*`, `MEDIA_TYPE_*`, `CRYPT_MODE_*` | не заданы: они относятся к лицензиям, выпуску запросов и шифрованию — сценариям, которых у нас нет |
| работа с несколькими устройствами одновременно | одна страница — одно устройство (`deviceRef`) |

**Если плагин КриптоПро уже установлен**, библиотека НЕ подменяет `window.cadesplugin` и пишет
предупреждение в консоль — иначе сайт, прекрасно работавший локально, начал бы неожиданно
отправлять подпись наружу. Подменить осознанно можно флагом `window.KeyOnline.force = true`;
без подмены библиотека всегда доступна как `window.KeyOnlinePlugin`.

---

## Отладка без сервера

Пока сервер не поднят или устройство не привязано, включите режим макета:

```js
window.KeyOnline = { mock: true };
```

Библиотека ответит тремя заранее заданными сертификатами (один из них намеренно просроченный)
и фиктивной подписью правдоподобной длины. Всю логику страницы — получение списка, выбор,
подпись, показ результата — так можно проверить целиком, не трогая криптографию.

Ещё полезно: `window.KeyOnline.debug = true` печатает в консоль все обращения к серверу.

Готовые примеры лежат рядом: `demo.html` (вход по ЭЦП) и `demo-sign.html` (подпись
произвольного текста). Обе страницы открываются и работают в режиме макета сразу.

---

## Что происходит под капотом

Библиотека делает всего два вида запросов к серверу «Ключ Онлайн».

**Список сертификатов** — кэшируется на 60 секунд, потому что сайты запрашивают его при каждой
загрузке страницы, а гонять ради этого задание на чужой компьютер незачем:

```
POST {server}/api/sign/certificates
X-Site-Key: {siteKey}
{ "deviceRef": "..." }

→ { "ok": true, "certs": [ { "thumbprint": "...", "subject": "...",
                             "validFrom": "...", "validTo": "..." } ] }
```

**Подпись:**

```
POST {server}/api/sign/sign
X-Site-Key: {siteKey}
{ "deviceRef": "...", "thumbprint": "...", "contentBase64": "...",
  "detached": false, "hash": false, "label": "имя вашего сайта" }

→ { "ok": true, "signatureBase64": "..." }
→ { "ok": false, "error": "текст для показа пользователю" }
```

Cookie не используются: сайт авторизуется ключом в заголовке, поэтому кража cookie у посетителя
ничего не даёт. Текст поля `error` библиотека показывает как есть — сервер пишет его сразу
по-русски и для человека.
