Перейти к содержанию

Web RP demo — руководство разработчика

Браузерное RP-демо платформы eID — пример доверяющей стороны на Next.js (App Router, TypeScript). Оно демонстрирует в браузере сценарий входа через QR / push по РД с помощью мобильного приложения eID Mongolia и наложения квалифицированной электронной подписи на PDF. Исходники: web/ (точный порт Java-демо smartid-demo: WebDemo.java + index.html — те же экраны, тот же сценарий, те же монгольские подписи).

Это настоящий RP. В отличие от клиентов для macOS/iOS, само веб-приложение хранит RP-секрет: RP_API_SECRET для Go RP-API (/v3/*) используется только в серверных route handlers (web/src/lib/rpclient.ts). Браузер никогда не видит этот секрет — все вызовы проксируются через публичные маршруты /api/* веб-приложения. Подробнее об интеграции RP: Интеграция RP; о десктопном клиенте, использующем те же /api/*: macOS desktop.

1. Что демонстрируется

  • Аутентификация — QR-код (/api/start) либо push по РД / civil ID (/api/login-notify). Подтверждение — PIN1 в приложении на телефоне. Серверный маршрут криптографически проверяет подпись над rpChallenge, затем извлекает из subject сертификата имя, civil ID и documentNumber и возвращает их (браузер сертификат не разбирает).
  • Подпись PDF — выберите PDF, его дайджест SHA-256 вычисляется локально на клиенте, и подпись выполняется с PIN2 (ключ подписи, неотказуемость) в приложении на телефоне. Бэкенд возвращает проштампованный PDF (PAdES / PKCS#7 + страница проверки) (/api/sign-pdf-download).
  • Представительство организации — после входа физического лица выбор «от имени какой организации продолжить» подтягивается из реестра в реальном времени (/api/representations, аналог эстонского äriregister).

Симуляция «виртуального телефона» не работает. Java-демо выполняло настоящую пороговую ECDSA через JVM SDK VirtualPhone/PhoneCrypto. Эта криптография существует только на стороне JVM, поэтому /api/simulate-phone возвращает 501 (dev) / 404 (prod) (web/src/app/api/simulate-phone/route.ts). Реальный сценарий: подтвердить QR/push в приложении eID Mongolia.

2. Архитектура

Браузер (page.tsx, demo/page.tsx)
   │  fetch /api/*  (same-origin, без секрета)
Route handler Next.js  (web/src/app/api/**, на сервере)
   │  rpclient.ts:  Authorization: Bearer <RP_API_SECRET>
Go RP-API  (/v3/*,  RP_API_BASE)

Java-демо обращалось к RP-API из встроенного лёгкого HTTP-сервера. В этом порте каждый вспомогательный бэкенд-эндпойнт стал route handler Next.js, который вызывает Go RP-API (/v3/...) со стороны сервера (без CORS, логика та же, что в Java). Контракт протокола 1:1 соответствует Go-DTO (server/internal/dto/*.go): ответы содержат sessionID, vc.value, result.endResult, result.documentNumber, cert.value (base64 DER), cert.certificateLevel.

Где хранятся секрет и идентификаторы RP

Значение Где Описание
RP_API_SECRET env, только на сервере Отправляется функцией authHeaders() из rpclient.ts как Authorization: Bearer. Не NEXT_PUBLIC_, поэтому не попадает в клиентский бандл. Если не задан, заголовок не добавляется (dev / RP-auth отключён).
RP_UUID / RP_NAME константы в rpclient.ts Не env — константы времени компиляции (RP_UUID = "2d87bd3a-…", RP_NAME = "Demo Bank"). Заранее зарегистрированы в relying_parties, стабильны между перезапусками.
rpChallenge на сервере, случайное 64 случайных байта (base64) на каждый запрос. Сохраняется в challengeStore по ключу sessionId и используется для проверки подписи AUTH по ACSP_V2 в /status.

pollToken (защита PII). Поскольку sessionId раскрывается в QR-коде, сам по себе он не даёт права читать имя/регистрационный номер/подпись. Каждый ответ start / login-notify / sign-pdf-start возвращает pollToken, и маршруты /api/status, /api/sign-pdf-download требуют этот токен (web/src/lib/pollTokenStore.ts).

3. Маршруты /api/*

Публичный демо-сценарий RP (page.tsx, demo/page.tsx, клиент macOS)

Маршрут Метод Что делает Эндпойнт Go RP-API
/api/start POST Запускает анонимную QR-сессию → {sessionId, qr, deviceLinkBase, vc, pollToken} (qr = sessionId) POST /v3/authentication/device-link/anonymous
/api/login-notify POST {register, callbackUrl} — push по РД / civil ID → {sessionId, vc, pollToken}. Rate limit 3 за 60 с на цель POST /v3/authentication/notification/etsi/{etsi}
/api/status GET ?sessionId=&pollToken= long-poll (сервер держит ~1 с). При COMPLETE/OK проверяет подпись AUTH над rpChallenge и извлекает из сертификата name/idNumber/documentNumber GET /v3/session/{id}?timeoutMs=1000
/api/sign-start POST {etsi, doc} — push для подписи текстового документа (PIN2) → {sessionId, vc, doc, pollToken}. Rate limit 3 за 60 с POST /v3/signature/notification/etsi/{etsi}
/api/sign-pdf-start POST {etsi, digestB64, fileName, onBehalfOf?} — сессия для подписи дайджеста SHA-256 PDF с PIN2 → {sessionId, vc, pollToken}. Дайджест должен быть 32 байта; rate limit 3 за 60 с POST /v3/signature/notification/etsi/{etsi}
/api/sign-pdf-download POST multipart file + sessionId + pollToken → байты проштампованного PDF (вложение application/pdf) POST /v3/signature/stamp/{sessionId}
/api/representations POST {personId} — организации в статусе ACTIVE, которые лицо может представлять → {personEtsi, representations}. Rate limit 10 за 60 с GET /v3/organization/representations/etsi/{personEtsi}
/api/simulate-phone POST Заглушка — 501 (dev) / 404 (prod). Требует настоящий JVM SDK пороговой ECDSA
/api/health GET {status:"ok", service:"eidmongolia-web"}

Прокси-сценарий /demo/live (web/src/app/api/demo/*)

Тонкий прокси для страницы живого демо на публичном сайте — он возвращает другой формат с ответами в snake_case, но по-прежнему проходит через rpclient.ts (RP-API /v3).

Маршрут Метод Что делает
/api/demo/auth/init POST Анонимная device-link аутентификация → {session_id, device_link_url, control_code, poll_token, expires_at} (device_link_url = сырой sessionId)
/api/demo/auth/poll GET ?id=&poll_token= — опрос сессии, проверка подписи AUTH и возврат идентичности
/api/demo/sign/init POST multipart file + заголовок x-eid-token (documentNumber, полученный при аутентификации) — вычисляет SHA-256 PDF и открывает церемонию PIN2 → {session_id, document_hash, verification_code}
/api/demo/sign/poll GET ?id= + x-eid-token — при COMPLETE/OK возвращает отделённую подпись ECDSA signature_hex; страница сама встраивает её в PDF через pdf-lib (здесь нет /download)

4. Запуск

cd web
npm install
npm run dev      # http://localhost:3000
npm run build    # проверка продакшн-сборки
npm run lint

npm run dev запускает web на :3000 и консоль администратора на :3001 (CLAUDE.md).

Переменные окружения

Переменная По умолчанию Описание
RP_API_BASE Базовый URL Go RP-API (только на сервере). Route handler сам добавляет /v3. Если не задан в продакшене, rpclient.ts выбрасывает ошибку
NEXT_PUBLIC_API_BASE Легаси-fallback (совместимость). Читается, если RP_API_BASE не задан
RP_API_SECRET Общий секрет RP. Должен совпадать с SMARTID_RP_API_SECRET на стороне Go. Если пуст, Bearer не добавляется (dev)
NODE_ENV При production simulate-phone возвращает 404, а RP_API_BASE обязателен

Dev-fallback. Если и RP_API_BASE, и NEXT_PUBLIC_API_BASE пусты, происходит откат на http://localhost:8080/v3 только в dev (rpclient.ts apiBase()). Поэтому для локального тестирования достаточно запустить Go-сервер на :8080.

Полный локальный стек (Go API + web):

cd server && SMARTID_RP_API_SECRET= go run ./cmd/smartid   # Go API :8080 (RP-auth отключён)
cd web    && npm run dev                                    # web :3000

Приложение на телефоне также должно указывать на тот же Go-сервер (на симуляторе — SMARTID_REQUIRE_ATTESTATION=false).

5. rpclient.ts — как он общается с RP-API

web/src/lib/rpclient.ts — копия Java-классов GeregeSmartIdRpClient + WebDemo. Он выполняется только на сервере (из route handlers).

  • Базовый URLapiBase() = RP_API_BASE (или NEXT_PUBLIC_API_BASE) + /v3, с удалением завершающего /.
  • АутентификацияauthHeaders() возвращает Authorization: Bearer <secret>, если задан RP_API_SECRET; иначе пустой заголовок (dev). Каждый post()/get() добавляет этот заголовок.
  • Тело запросаrelyingPartyUUID, relyingPartyName, certificateLevel (QUALIFIED), signatureProtocol (ACSP_V2), interactions, а при необходимости rpChallenge / digest + hashType / initialCallbackUrl / onBehalfOf.
  • Таймаут — 15 с для обычных вызовов, 140 с для long-poll (AbortSignal.timeout).
  • Штамп PDFstampSignedPdf() отправляет исходный PDF в POST /v3/signature/stamp/{sessionId} как application/pdf для завершённой SIGN-сессии и получает обратно проштампованный PDF.

web/src/lib/x509subject.ts — минимальный парсер ASN.1 DER, который извлекает имя и регистрационный номер (serialNumber) из subject сертификата (замена BouncyCastle из Java).

6. Ссылки

Исходники (GitHub)

  • Веб-демо: https://github.com/gerege-systems/eid-platform-mn/tree/main/web
  • Клиент RP-API: https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/lib/rpclient.ts
  • Route handlers: https://github.com/gerege-systems/eid-platform-mn/tree/main/web/src/app/api
  • /api/start: https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/start/route.ts
  • /api/status: https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/status/route.ts
  • /api/sign-pdf-download: https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/sign-pdf-download/route.ts

Next.js 16. Репозиторий использует Next.js 16 (с ломающими изменениями). Прежде чем писать код на Next.js, следуйте указаниям в web/AGENTS.md и сверяйтесь с node_modules/next/dist/docs/, а не с данными обучения.