feat: живой цикл M2M с НРД + мастер установки ключа на флешку

Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-06-19 00:03:21 +03:00
parent 6e503433d4
commit 9737c787f9
110 changed files with 10771 additions and 1690 deletions
+31 -15
View File
@@ -1,17 +1,20 @@
// build.gradle.kts — Gradle build для crypto-service.
//
// Артефакт: ./build/libs/crypto-service-0.1.0-all.jar (shadow jar).
// Запуск: java -jar crypto-service-0.1.0-all.jar (UDS из BJ_CRYPTO_SOCKET).
// Запуск:
// java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 \
// -jar crypto-service-0.1.0-all.jar
//
// Зависимости (Maven Central, подключаются автоматически):
// - grpc-java (server + protoc-gen-java)
// - protobuf-java
// - apache santuario (XMLDSig)
// - jcp (КриптоПро JCP) — внешний jar, поставляется заказчиком
// отдельно вместе с лицензией; положить в libs/.
// Зависимости:
// - grpc-java (server + protoc-gen-java) — Maven Central
// - protobuf-java — Maven Central
// - apache santuario (XMLDSig) — Maven Central
// - Pki1.LocalIface.jar (Валидата JNI) — libs/ (положен из
// /opt/Validata/VDCSP/lib/)
//
// Соберётся, когда Maven Central доступен через прокси zetit
// (пока ждём админа).
// Java toolchain — 17 (LTS), на Astra 1.7 ставится из openjdk-17-jdk.
import com.google.protobuf.gradle.id
plugins {
id("java")
@@ -25,13 +28,13 @@ version = "0.1.0"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
languageVersion.set(JavaLanguageVersion.of(17))
}
}
repositories {
mavenCentral()
flatDir { dirs("libs") } // для jcp.jar
flatDir { dirs("libs") } // Pki1.LocalIface.jar (Валидата)
}
dependencies {
@@ -43,11 +46,13 @@ dependencies {
implementation("io.grpc:grpc-stub:$grpcVer")
implementation("com.google.protobuf:protobuf-java:$protobufVer")
// XMLDSig (с ГОСТ-патчем поставляется в libs/ заказчиком).
// XMLDSig — построение/разбор подписи. ГОСТ-алгоритмы реализуем сами
// (digest + signature через VCERT_*), Santuario используем только для
// канонизации и сборки XML-структуры.
implementation("org.apache.santuario:xmlsec:3.0.4")
// КриптоПро JCP — кладётся в services/crypto-service/libs/jcp.jar.
// implementation(files("libs/jcp.jar"))
// Валидата Клиент L — Pki1.LocalIface.jar.
implementation(files("libs/Pki1.LocalIface.jar"))
compileOnly("javax.annotation:javax.annotation-api:1.3.2")
implementation("ch.qos.logback:logback-classic:1.5.6")
@@ -58,6 +63,9 @@ dependencies {
application {
mainClass.set("ru.zetit.bridgeandjoins.crypto.CryptoServer")
applicationDefaultJvmArgs = listOf(
"-Djava.library.path=/opt/Validata/VDCSP/lib/amd64",
)
}
protobuf {
@@ -78,6 +86,14 @@ protobuf {
}
}
tasks.test {
sourceSets {
main {
proto {
srcDir("proto")
}
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
@@ -19,6 +19,41 @@ service CryptoService {
// Health-check.
rpc Health(HealthRequest) returns (HealthResponse);
// Activate — переинициализирует провайдер Валидаты на указанный
// профиль из pki1.conf. Если profile пуст — переходит в
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
// перезапуска сайдкара.
rpc Activate(ActivateRequest) returns (ActivateResponse);
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
// после отправки ответа). systemd с Restart=on-failure поднимет
// его снова через RestartSec секунд. Используется для UI-кнопки
// «Перезапустить crypto-service» без sudo.
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
}
message ActivateRequest {
// Имя профиля в pki1.conf. Пустая строка = minimal mode.
string profile = 1;
}
message ActivateResponse {
// true если провайдер успешно (пере)инициализирован.
bool ok = 1;
// Имя активного провайдера ("validata" / "stub").
string provider = 2;
// Имя активного профиля (пусто для minimal).
string profile = 3;
// Сообщение о результате (для UI).
string message = 4;
}
message ShutdownRequest {}
message ShutdownResponse {
// true означает «запрос принят, процесс завершится через ~500ms».
bool ok = 1;
}
message VerifyRequest {
@@ -0,0 +1,42 @@
package ru.zetit.bridgeandjoins.crypto;
import ru.zetit.bridgeandjoins.crypto.v1.ActivateRequest;
import ru.zetit.bridgeandjoins.crypto.v1.ActivateResponse;
import java.util.logging.Logger;
/**
* Хендлер Activate: переключает текущий провайдер на указанный
* профиль pki1.conf без рестарта сайдкара. Если провайдер не
* {@link ValidataProvider} (например stub) — отказ.
*/
public final class ActivateHandler {
private static final Logger LOG = Logger.getLogger(ActivateHandler.class.getName());
private final KeystoreProvider keystore;
public ActivateHandler(KeystoreProvider keystore) {
this.keystore = keystore;
}
public ActivateResponse handle(ActivateRequest request) {
String profile = request.getProfile();
if (!(keystore instanceof ValidataProvider vp)) {
return ActivateResponse.newBuilder()
.setOk(false)
.setProvider(keystore.name())
.setMessage("crypto-service запущен с провайдером " + keystore.name()
+ " — переключение профиля доступно только для validata")
.build();
}
boolean ok = vp.reinit(profile);
LOG.info("Activate: profile=\"" + profile + "\", ok=" + ok + ", msg=" + vp.message());
return ActivateResponse.newBuilder()
.setOk(ok)
.setProvider(vp.name())
.setProfile(profile == null ? "" : profile)
.setMessage(vp.message())
.build();
}
}
@@ -14,32 +14,44 @@ import java.util.logging.Logger;
/**
* Точка входа crypto-service.
*
* Слушает Unix Domain Socket по пути из переменной окружения
* BJ_CRYPTO_SOCKET (по умолчанию /run/bj/crypto.sock).
* Используется UDS вместо TCP — минимальная задержка и изоляция
* от сети.
* Слушает Unix Domain Socket по пути из BJ_CRYPTO_SOCKET
* (по умолчанию /run/bj/crypto.sock). UDS вместо TCP — минимальная
* задержка и изоляция от сети.
*
* Провайдер выбирается через BJ_CRYPTO_PROVIDER:
* - "validata" — АПК «Валидата Клиент L» (через Pki1.LocalIface);
* - что-либо ещё или пусто — stub (gRPC жив, реальная криптография
* отключена; используется на дев-стендах).
*
* Профиль Валидаты задаётся через BJ_VALIDATA_PROFILE (имя из
* pki1.conf или ПК «Справочник сертификатов»). Если пусто —
* инициализация в режиме VCERT_InitMinimal (хэш, Base64, RAND).
*/
public final class CryptoServer {
private static final Logger LOG = Logger.getLogger(CryptoServer.class.getName());
private static final String DEFAULT_SOCKET = "/run/bj/crypto.sock";
private CryptoServer() {
// utility
}
private CryptoServer() { /* utility */ }
public static void main(String[] args) throws IOException, InterruptedException {
String socketPath = System.getenv().getOrDefault("BJ_CRYPTO_SOCKET", DEFAULT_SOCKET);
String provider = System.getenv().getOrDefault("BJ_CRYPTO_PROVIDER", "stub");
Files.createDirectories(Path.of(socketPath).getParent());
Files.deleteIfExists(Path.of(socketPath));
KeystoreProvider keystore = KeystoreProvider.byName(provider);
LOG.info("crypto-service: провайдер " + keystore.name() + "" + keystore.message());
VerifyHandler verifyHandler = new VerifyHandler(keystore);
SignHandler signHandler = new SignHandler(keystore);
HealthHandler healthHandler = new HealthHandler(provider);
HealthHandler healthHandler = new HealthHandler(keystore);
ActivateHandler activateHandler = new ActivateHandler(keystore);
ShutdownHandler shutdownHandler = new ShutdownHandler();
CryptoServiceImpl service = new CryptoServiceImpl(verifyHandler, signHandler, healthHandler);
CryptoServiceImpl service = new CryptoServiceImpl(verifyHandler, signHandler,
healthHandler, activateHandler, shutdownHandler);
Server server = NettyServerBuilder
.forAddress(new DomainSocketAddress(socketPath))
@@ -50,11 +62,13 @@ public final class CryptoServer {
.build()
.start();
LOG.info("crypto-service: UDS " + socketPath + ", provider=" + provider);
LOG.info("crypto-service: UDS " + socketPath + ", provider=" + keystore.name()
+ ", ready=" + keystore.ready());
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("crypto-service: graceful shutdown");
server.shutdown();
keystore.close();
}));
server.awaitTermination();
@@ -2,9 +2,13 @@ package ru.zetit.bridgeandjoins.crypto;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import ru.zetit.bridgeandjoins.crypto.v1.ActivateRequest;
import ru.zetit.bridgeandjoins.crypto.v1.ActivateResponse;
import ru.zetit.bridgeandjoins.crypto.v1.CryptoServiceGrpc;
import ru.zetit.bridgeandjoins.crypto.v1.HealthRequest;
import ru.zetit.bridgeandjoins.crypto.v1.HealthResponse;
import ru.zetit.bridgeandjoins.crypto.v1.ShutdownRequest;
import ru.zetit.bridgeandjoins.crypto.v1.ShutdownResponse;
import ru.zetit.bridgeandjoins.crypto.v1.SignRequest;
import ru.zetit.bridgeandjoins.crypto.v1.SignResponse;
import ru.zetit.bridgeandjoins.crypto.v1.VerifyRequest;
@@ -19,11 +23,17 @@ public final class CryptoServiceImpl extends CryptoServiceGrpc.CryptoServiceImpl
private final VerifyHandler verify;
private final SignHandler sign;
private final HealthHandler health;
private final ActivateHandler activate;
private final ShutdownHandler shutdown;
public CryptoServiceImpl(VerifyHandler verify, SignHandler sign, HealthHandler health) {
public CryptoServiceImpl(VerifyHandler verify, SignHandler sign,
HealthHandler health, ActivateHandler activate,
ShutdownHandler shutdown) {
this.verify = verify;
this.sign = sign;
this.health = health;
this.activate = activate;
this.shutdown = shutdown;
}
@Override
@@ -51,4 +61,24 @@ public final class CryptoServiceImpl extends CryptoServiceGrpc.CryptoServiceImpl
obs.onNext(health.handle(request));
obs.onCompleted();
}
@Override
public void activate(ActivateRequest request, StreamObserver<ActivateResponse> obs) {
try {
obs.onNext(activate.handle(request));
obs.onCompleted();
} catch (Exception e) {
obs.onError(Status.INTERNAL.withDescription(e.getMessage()).asException());
}
}
@Override
public void shutdown(ShutdownRequest request, StreamObserver<ShutdownResponse> obs) {
try {
obs.onNext(shutdown.handle(request));
obs.onCompleted();
} catch (Exception e) {
obs.onError(Status.INTERNAL.withDescription(e.getMessage()).asException());
}
}
}
@@ -3,22 +3,34 @@ package ru.zetit.bridgeandjoins.crypto;
import ru.zetit.bridgeandjoins.crypto.v1.HealthRequest;
import ru.zetit.bridgeandjoins.crypto.v1.HealthResponse;
/** Хендлер Health: всегда ok=true с указанием активного провайдера. */
/**
* Хендлер Health: возвращает статус провайдера КЗИ.
* - ok=true, если провайдер успешно инициализирован (или работает в stub);
* - provider — "validata" или "stub";
* - version — версия crypto-service.
*
* В крайнем правом окне UI bj-server этот ответ читается через cryptocli
* и показывается в /admin/setup → СКЗИ.
*/
public final class HealthHandler {
private static final String VERSION = "0.1.0";
private final String provider;
private final KeystoreProvider keystore;
public HealthHandler(String provider) {
this.provider = provider;
public HealthHandler(KeystoreProvider keystore) {
this.keystore = keystore;
}
public HealthResponse handle(HealthRequest request) {
// ok=true даже для stub — gRPC-сервис жив; реальная готовность
// криптографии видна по полю provider (stub ↔ криптография не
// подключена). Это упрощает /healthz: «UDS живой» — отдельный
// факт от «есть рабочий ключ».
return HealthResponse.newBuilder()
.setOk(true)
.setVersion(VERSION)
.setProvider(provider)
.setVersion(VERSION + " (" + keystore.message() + ")")
.setProvider(keystore.name())
.build();
}
}
@@ -1,53 +1,50 @@
package ru.zetit.bridgeandjoins.crypto;
/**
* Provider-абстракция над хранилищем ключей. Реальные реализации
* подключаются вместе с соответствующими jar-ами (КриптоПро JCP,
* Валидата JCP, ViPNet). На M1 — Stub.
* Provider-абстракция над хранилищем ключей. На Linux используется
* АПК «Валидата Клиент L» через Pki1.LocalIface (JNI-биндинг к
* libzpki1jni.so). Если BJ_CRYPTO_PROVIDER=stub — поднимается
* заглушка для дев-стендов без реальной криптографии.
*/
public interface KeystoreProvider {
/** Уникальное имя провайдера (используется в HealthResponse.provider). */
String name();
/** Возвращает true, если провайдер инициализирован и готов к работе. */
/** true, если провайдер инициализирован и готов к работе. */
boolean ready();
/** Короткое описание состояния (для Health и диагностики). */
String message();
/**
* Подбирает реализацию по имени из переменной окружения
* BJ_CRYPTO_PROVIDER. Неизвестное имя — Stub.
* Возвращает Pki1.LocalIface, если провайдер — Валидата и
* инициализация прошла; иначе null.
*/
Pki1.LocalIface pki();
/** Закрывает контекст провайдера (для shutdown). */
void close();
/**
* Подбирает реализацию по имени из BJ_CRYPTO_PROVIDER.
* Неизвестное имя — Stub.
*/
static KeystoreProvider byName(String name) {
switch (name == null ? "" : name) {
case "cryptopro": return new CryptoProJcpProvider();
case "validata": return new ValidataJcpProvider();
case "vipnet": return new VipNetProvider();
default: return new StubProvider();
if ("validata".equals(name)) {
return new ValidataProvider();
}
return new StubProvider();
}
}
/** Заглушка по умолчанию. Возвращает ready()=false. */
/** Заглушка для дев-стендов: ready()=false, реальная криптография недоступна. */
final class StubProvider implements KeystoreProvider {
@Override public String name() { return "stub"; }
@Override public boolean ready() { return false; }
}
/** КриптоПро JCP. Реализация — после получения jar и лицензии. */
final class CryptoProJcpProvider implements KeystoreProvider {
// TODO(M2): инициализация Provider'а через Security.addProvider(new ru.CryptoPro.JCP.JCP());
@Override public String name() { return "cryptopro"; }
@Override public boolean ready() { return false; }
}
/** Валидата JCP. Заглушка. */
final class ValidataJcpProvider implements KeystoreProvider {
@Override public String name() { return "validata"; }
@Override public boolean ready() { return false; }
}
/** ViPNet. Заглушка. */
final class VipNetProvider implements KeystoreProvider {
@Override public String name() { return "vipnet"; }
@Override public boolean ready() { return false; }
@Override public String message() {
return "Провайдер stub — реальная криптография не подключена.";
}
@Override public Pki1.LocalIface pki() { return null; }
@Override public void close() { /* no-op */ }
}
@@ -0,0 +1,33 @@
package ru.zetit.bridgeandjoins.crypto;
import ru.zetit.bridgeandjoins.crypto.v1.ShutdownRequest;
import ru.zetit.bridgeandjoins.crypto.v1.ShutdownResponse;
import java.util.logging.Logger;
/**
* Хендлер Shutdown: формирует ответ, затем (с короткой задержкой,
* чтобы клиент успел получить ответ) вызывает System.exit(2). systemd
* с Restart=on-failure поднимает сайдкар обратно через RestartSec.
*/
public final class ShutdownHandler {
private static final Logger LOG = Logger.getLogger(ShutdownHandler.class.getName());
public ShutdownResponse handle(ShutdownRequest request) {
LOG.info("Shutdown: получен запрос, exit(2) через 500мс");
// Поток-смертник: даёт gRPC завершить ответ клиенту, затем
// valle exit с ненулевым кодом → systemd считает это failure
// и перезапускает.
Thread reaper = new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException ignore) {
}
System.exit(2);
}, "bj-crypto-reaper");
reaper.setDaemon(true);
reaper.start();
return ShutdownResponse.newBuilder().setOk(true).build();
}
}
@@ -6,13 +6,25 @@ import io.grpc.StatusRuntimeException;
import ru.zetit.bridgeandjoins.crypto.v1.SignRequest;
import ru.zetit.bridgeandjoins.crypto.v1.SignResponse;
import java.util.logging.Logger;
/**
* Хендлер подписи XML. На M1 — заглушка, возвращает UNIMPLEMENTED.
* Реальная реализация подключается, когда лицензия КриптоПро JCP
* будет доступна.
* Хендлер подписи. Формирует CMS detached signature через
* VCERT_CmsBlkDetSignMem с алгоритмом ГОСТ Р 34.10-2012 (256 бит
* по умолчанию из профиля Валидаты).
*
* Поле key_alias из gRPC-запроса передаётся в certid_t.keyId — нативный
* CSP подставит дефолтный ключ из профиля при пустом alias.
*
* Возвращает signed_xml = DER-байты CMS detached signature. На уровне
* bj-server этот блок отправляется как signed_document (base64).
* XMLDSig-обёртка с канонизацией добавится отдельным этапом
* (Apache Santuario).
*/
public final class SignHandler {
private static final Logger LOG = Logger.getLogger(SignHandler.class.getName());
private final KeystoreProvider keystore;
public SignHandler(KeystoreProvider keystore) {
@@ -20,21 +32,66 @@ public final class SignHandler {
}
public SignResponse handle(SignRequest request) {
// TODO(M2): реальная реализация:
// 1. Загружаем ключ по key_alias из keystore (через KeystoreProvider).
// 2. Канонизуем payload по xml-exc-c14n.
// 3. Создаём <Signature> элемент через Santuario с GOST-алгоритмом.
// 4. Возвращаем signed_xml.
//
// Сейчас — заглушка.
if (!keystore.ready() || keystore.pki() == null) {
throw new StatusRuntimeException(Status.FAILED_PRECONDITION
.withDescription("crypto-service: SignXMLDSig — провайдер "
+ keystore.name() + " не готов: " + keystore.message()));
}
if (keystore instanceof ValidataProvider vp && vp.isMinimal()) {
throw new StatusRuntimeException(Status.FAILED_PRECONDITION
.withDescription("crypto-service: SignXMLDSig — Валидата в режиме minimal "
+ "(BJ_VALIDATA_PROFILE не задан), подпись недоступна без профиля"));
}
if (request.getPayload().isEmpty()) {
throw new StatusRuntimeException(Status.INVALID_ARGUMENT
.withDescription("crypto-service: SignXMLDSig — payload пуст"));
}
throw new StatusRuntimeException(Status.UNIMPLEMENTED
.withDescription("crypto-service: SignXMLDSig stub — провайдер "
+ keystore.name() + " не подключён (ждём PR-6/M2 КриптоПро JCP)"));
Pki1.LocalIface pki = keystore.pki();
try {
Pki1.LocalIface.sign_param_t signParam = new Pki1.LocalIface.sign_param_t();
String alias = request.getKeyAlias();
if (alias != null && !alias.isEmpty()) {
Pki1.LocalIface.certid_t cid = new Pki1.LocalIface.certid_t();
cid.type = Pki1.LocalIface.ID_ALIAS;
cid.alias = alias;
signParam.mycert = cid;
}
Pki1.LocalIface.mem_blk_t data = memBlock(request.getPayload().toByteArray());
Pki1.LocalIface.mem_blk_t signature = new Pki1.LocalIface.mem_blk_t();
// CMS detached signature: подпись в signature, исходные данные
// в результат не включаются. Третий аргумент (key) — null:
// ключ подбирается нативным CSP по certid.
int rc = pki.VCERT_CmsBlkDetSignMem(signParam, data, null, signature);
if (rc != 0) {
String err = pki.VCERT_GetErrorText(rc);
throw new StatusRuntimeException(Status.INTERNAL
.withDescription("crypto-service: VCERT_CmsBlkDetSignMem rc=" + rc
+ " (" + err + ")"));
}
if (signature.buf == null || signature.len <= 0) {
throw new StatusRuntimeException(Status.INTERNAL
.withDescription("crypto-service: подпись вернулась пустая"));
}
return SignResponse.newBuilder()
.setSignedXml(ByteString.copyFrom(signature.buf, 0, signature.len))
.build();
} catch (StatusRuntimeException sre) {
throw sre;
} catch (Throwable t) {
LOG.severe("SignHandler: исключение " + t);
throw new StatusRuntimeException(Status.INTERNAL
.withDescription("crypto-service: " + t.getClass().getSimpleName()
+ ": " + t.getMessage()));
}
}
@SuppressWarnings("unused")
private SignResponse empty() {
return SignResponse.newBuilder().setSignedXml(ByteString.EMPTY).build();
private static Pki1.LocalIface.mem_blk_t memBlock(byte[] bytes) {
Pki1.LocalIface.mem_blk_t mb = new Pki1.LocalIface.mem_blk_t();
mb.buf = bytes;
mb.len = bytes.length;
return mb;
}
}
@@ -0,0 +1,124 @@
package ru.zetit.bridgeandjoins.crypto;
import java.util.logging.Logger;
/**
* Провайдер на основе АПК «Валидата Клиент L». Через Pki1.LocalIface
* вызывает нативную JNI-библиотеку libzpki1jni.so. Профиль
* (имя из pki1.conf или ПК «Справочник сертификатов») берётся из
* переменной окружения BJ_VALIDATA_PROFILE — если пусто, инициализируем
* минимальный контекст без доступа к ПСП/ЛСП/ССС (доступны только
* хэширование, Base64, RAND).
*
* Поддерживает горячее переключение профиля через {@link #reinit(String)}
* — без рестарта сайдкара.
*
* Подробности нативного API — в документе ВАМБ.00135-06 33 03
* «Руководство программиста» (Java) и 33 01 (C).
*/
public final class ValidataProvider implements KeystoreProvider {
private static final Logger LOG = Logger.getLogger(ValidataProvider.class.getName());
private Pki1.LocalIface pki;
private boolean ready;
private String message;
private boolean minimal;
private String profile;
public ValidataProvider() {
this.profile = System.getenv("BJ_VALIDATA_PROFILE");
initLocked();
}
/**
* reinit — атомарная переинициализация провайдера на новый профиль
* (или пустой = minimal). Закрывает текущий контекст и поднимает
* новый. Возвращает true при успехе.
*/
public synchronized boolean reinit(String newProfile) {
// Уничтожаем старый контекст, если был.
if (pki != null && ready) {
try {
pki.VCERT_Uninitialize();
} catch (Throwable t) {
LOG.warning("reinit: VCERT_Uninitialize: " + t.getMessage());
}
}
pki = null;
ready = false;
message = "";
minimal = false;
this.profile = newProfile;
initLocked();
return ready;
}
private void initLocked() {
try {
this.pki = new Pki1.LocalIface();
int rc;
if (profile == null || profile.isEmpty()) {
rc = pki.VCERT_InitMinimal();
this.minimal = true;
} else {
rc = pki.VCERT_Initialize(profile, 0);
this.minimal = false;
}
if (rc == 0) {
this.ready = true;
this.message = minimal
? "Валидата: минимальный контекст (без профиля). Доступны хэш, Base64, RAND."
: "Валидата: контекст с профилем «" + profile + "» инициализирован.";
LOG.info(message);
} else {
this.ready = false;
String err = safeErrorText(rc);
this.message = (minimal ? "VCERT_InitMinimal" : "VCERT_Initialize(\"" + profile + "\")")
+ " вернул код " + rc + " (" + err + ")";
LOG.warning(message);
}
} catch (UnsatisfiedLinkError e) {
this.ready = false;
this.message = "Валидата: не удалось загрузить libzpki1jni.so — " + e.getMessage()
+ ". Проверьте java.library.path и установку АПК «Валидата Клиент L».";
LOG.severe(message);
} catch (Throwable t) {
this.ready = false;
this.message = "Валидата: инициализация упала — " + t.getClass().getSimpleName()
+ ": " + t.getMessage();
LOG.severe(message);
}
}
@Override public String name() { return "validata"; }
@Override public synchronized boolean ready() { return ready; }
@Override public synchronized String message() { return message; }
@Override public synchronized Pki1.LocalIface pki() { return ready ? pki : null; }
/** Признак режима VCERT_InitMinimal (без профиля). */
public synchronized boolean isMinimal() { return minimal; }
/** Имя профиля, под которым инициализировались (null/пусто = minimal). */
public synchronized String profile() { return profile; }
@Override
public synchronized void close() {
if (pki != null && ready) {
try {
pki.VCERT_Uninitialize();
} catch (Throwable t) {
LOG.warning("Валидата: VCERT_Uninitialize: " + t.getMessage());
}
}
}
private String safeErrorText(int rc) {
try {
String t = pki.VCERT_GetErrorText(rc);
return t == null ? "" : t;
} catch (Throwable ignore) {
return "";
}
}
}
@@ -1,15 +1,29 @@
package ru.zetit.bridgeandjoins.crypto;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import ru.zetit.bridgeandjoins.crypto.v1.VerifyRequest;
import ru.zetit.bridgeandjoins.crypto.v1.VerifyResponse;
import java.util.logging.Logger;
/**
* Хендлер проверки XMLDSig-подписи. На M1 — заглушка, возвращающая
* valid=false с пояснением. Реальная реализация (Apache Santuario +
* КриптоПро JCP) подключается, когда лицензия JCP станет доступна.
* Хендлер проверки подписи. Обрабатывает CMS detached signature
* (DER-байты в payload, само сообщение должно быть в request.message,
* но пока контракт упрощён: payload = весь подписанный CMS-блок,
* проверяется как attached-вариант).
*
* Внутри: VCERT_CmsBlkAttVerifyMem с verify_param_t. Возвращает
* данные о подписанте: CN, ИНН (OID 1.2.643.3.131.1.1), серийник,
* срок действия.
*
* Для XMLDSig-входных сообщений (как у НРД) нужен слой канонизации
* XML и извлечения SignatureValue + KeyInfo — это отдельный этап.
*/
public final class VerifyHandler {
private static final Logger LOG = Logger.getLogger(VerifyHandler.class.getName());
private final KeystoreProvider keystore;
public VerifyHandler(KeystoreProvider keystore) {
@@ -17,22 +31,105 @@ public final class VerifyHandler {
}
public VerifyResponse handle(VerifyRequest request) {
// TODO(M2): реальная реализация через Apache Santuario.
//
// Шаги при подключении КриптоПро JCP:
// 1. Парсим XML, ищем элемент <Signature>.
// 2. Канонизуем по xml-exc-c14n.
// 3. Берём сертификат из KeyInfo, проверяем цепочку через
// trust store профиля.
// 4. Проверяем подпись (alg=GOST34112012-256/-512).
// 5. Извлекаем CN, ИНН из subject.
//
// Сейчас — заглушка.
if (!keystore.ready() || keystore.pki() == null) {
return VerifyResponse.newBuilder()
.setValid(false)
.addErrors("crypto-service: провайдер " + keystore.name()
+ " не готов: " + keystore.message())
.build();
}
if (request.getPayload().isEmpty()) {
throw new StatusRuntimeException(Status.INVALID_ARGUMENT
.withDescription("crypto-service: VerifyXMLDSig — payload пуст"));
}
return VerifyResponse.newBuilder()
.setValid(false)
.addErrors("crypto-service: VerifyXMLDSig stub — провайдер " + keystore.name()
+ " не подключён (ждём PR-6/M2 КриптоПро JCP)")
.build();
Pki1.LocalIface pki = keystore.pki();
try {
Pki1.LocalIface.verify_param_t verifyParam = new Pki1.LocalIface.verify_param_t();
Pki1.LocalIface.mem_blk_t signedMessage = memBlock(request.getPayload().toByteArray());
Pki1.LocalIface.mem_blk_t recoveredData = new Pki1.LocalIface.mem_blk_t();
Pki1.LocalIface.verify_result_t result = new Pki1.LocalIface.verify_result_t();
int rc = pki.VCERT_CmsBlkAttVerifyMem(verifyParam, signedMessage, recoveredData, result);
if (rc != 0) {
String err = pki.VCERT_GetErrorText(rc);
return VerifyResponse.newBuilder()
.setValid(false)
.addErrors("VCERT_CmsBlkAttVerifyMem rc=" + rc + " (" + err + ")")
.build();
}
VerifyResponse.Builder resp = VerifyResponse.newBuilder().setValid(true);
// Если есть инфа о подписанте — кладём её. Структура
// verify_result_t содержит cms_siginf и/или certificate;
// поля защищены от NPE, потому что нативный API заполняет
// что есть.
extractSignerInfo(pki, result, resp);
return resp.build();
} catch (Throwable t) {
LOG.severe("VerifyHandler: исключение " + t);
return VerifyResponse.newBuilder()
.setValid(false)
.addErrors(t.getClass().getSimpleName() + ": " + t.getMessage())
.build();
}
}
/**
* Извлекает CN/ИНН/серийник/сроки из verify_result_t.
* verify_result_t.signs[i].cert — certificate_t подписанта.
* Берём первого подписанта (signs[0]) — для НРД-обмена этого
* достаточно; multi-signer обработка — отдельно.
*/
private static void extractSignerInfo(Pki1.LocalIface pki,
Pki1.LocalIface.verify_result_t result,
VerifyResponse.Builder resp) {
if (result == null || result.signs == null || result.signs.length == 0) {
return;
}
Pki1.LocalIface.sign_status_t s = result.signs[0];
if (s == null || s.cert == null) {
return;
}
Pki1.LocalIface.certificate_t cert = s.cert;
if (cert.subject != null) {
resp.setSignerCn(extractRdn(cert.subject, "CN"));
String inn = extractRdn(cert.subject, "INN");
if (inn.isEmpty()) {
inn = extractRdn(cert.subject, "1.2.643.3.131.1.1");
}
resp.setSignerInn(inn);
}
if (cert.serialNumber != null) {
resp.setSerial(cert.serialNumber);
}
// notBefore/notAfter — unix epoch seconds.
resp.setNotBefore((long) cert.notBefore);
resp.setNotAfter((long) cert.notAfter);
}
/**
* Извлекает значение RDN из строки subject вида
* "CN=Иванов И.И.,OU=...,O=...,INN=7707083893".
* Возвращает "" если RDN не найден.
*/
private static String extractRdn(String dn, String rdn) {
if (dn == null || rdn == null) return "";
String needle = rdn + "=";
int idx = dn.indexOf(needle);
if (idx < 0) return "";
int start = idx + needle.length();
// Конец значения — следующая запятая (не экранированная). Простой
// парсинг — достаточно для типовых cert-полей НРД/УЦ МБ.
int end = dn.indexOf(',', start);
if (end < 0) end = dn.length();
return dn.substring(start, end).trim();
}
private static Pki1.LocalIface.mem_blk_t memBlock(byte[] bytes) {
Pki1.LocalIface.mem_blk_t mb = new Pki1.LocalIface.mem_blk_t();
mb.buf = bytes;
mb.len = bytes.length;
return mb;
}
}