diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml
index d7d8eb9..0697f73 100644
--- a/deploy/docker-compose/docker-compose.yml
+++ b/deploy/docker-compose/docker-compose.yml
@@ -32,6 +32,20 @@ services:
volumes:
- bj-minio-data:/data
+ crypto-service:
+ build:
+ context: ../../services/crypto-service
+ dockerfile: Dockerfile
+ container_name: bj-crypto-service
+ environment:
+ BJ_CRYPTO_SOCKET: /run/bj/crypto.sock
+ BJ_CRYPTO_PROVIDER: stub
+ volumes:
+ # UDS-сокет наружу как named volume, чтобы Go-сервисы
+ # (m2m-core, lk-gateway, nsd-adapter) могли его mount'ить.
+ - bj-crypto-sock:/run/bj
+
volumes:
bj-postgres-data:
bj-minio-data:
+ bj-crypto-sock:
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index ce8db11..cf55d81 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -16,7 +16,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
-| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
+| PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 |
## Как запустить задачу
diff --git a/internal/cryptocli/README.md b/internal/cryptocli/README.md
new file mode 100644
index 0000000..a5dfd2e
--- /dev/null
+++ b/internal/cryptocli/README.md
@@ -0,0 +1,29 @@
+# internal/cryptocli — Go-клиент crypto-service
+
+Реализация `m2mcore.CryptoVerifier` поверх gRPC по Unix Domain Socket.
+
+## Состояние
+
+На M1 — заглушка. Подключается к UDS-сокету crypto-service, проверяет
+доступность и возвращает `ErrNotImplemented`. Этого достаточно, чтобы:
+
+- m2m-core и другие сервисы могли инжектить клиент без условных веток;
+- логи различали «сокета нет» (например, контейнер crypto-service не
+ запущен) от «сокет есть, но криптография не подключена» (нет лицензии
+ КриптоПро JCP).
+
+## Когда станет полноценным
+
+После генерации gRPC-стабов из `services/crypto-service/proto/crypto.proto`
+(требует `protoc` + плагины), что в свою очередь требует доступа к
+Maven Central / Go module proxy через прокси zetit.
+
+## API
+
+```go
+cli := cryptocli.NewClient("/run/bj/crypto.sock")
+info, err := cli.VerifyXMLDSig(ctx, signedXML)
+if errors.Is(err, cryptocli.ErrNotImplemented) {
+ // M1: запасной путь (ручная проверка / откладывание).
+}
+```
diff --git a/internal/cryptocli/client.go b/internal/cryptocli/client.go
new file mode 100644
index 0000000..733d1f0
--- /dev/null
+++ b/internal/cryptocli/client.go
@@ -0,0 +1,61 @@
+// Package cryptocli — Go-клиент crypto-service по UDS. На M1 — заглушка,
+// возвращает ErrNotImplemented. Реальная реализация — после
+// генерации gRPC-стабов из proto (когда прокси откроет proto-tooling
+// и Go-зависимости).
+package cryptocli
+
+import (
+ "context"
+ "errors"
+ "net"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// ErrNotImplemented возвращается клиентом до подключения реального gRPC.
+var ErrNotImplemented = errors.New("cryptocli: не реализовано (ждём gRPC-стабы из proto)")
+
+// Client — клиент crypto-service по Unix Domain Socket.
+type Client struct {
+ socketPath string
+ dialer net.Dialer
+ timeout time.Duration
+}
+
+// Option настраивает Client.
+type Option func(*Client)
+
+// WithTimeout задаёт таймаут на одну операцию.
+func WithTimeout(t time.Duration) Option {
+ return func(c *Client) { c.timeout = t }
+}
+
+// NewClient создаёт клиента к UDS. На M1 — без реального gRPC.
+// Используется как заглушка, реализующая m2mcore.CryptoVerifier.
+func NewClient(socketPath string, opts ...Option) *Client {
+ c := &Client{socketPath: socketPath, timeout: 5 * time.Second}
+ for _, o := range opts {
+ o(c)
+ }
+ return c
+}
+
+// VerifyXMLDSig вызывает CryptoService.VerifyXMLDSig. На M1 — stub.
+func (c *Client) VerifyXMLDSig(ctx context.Context, _ []byte) (m2mcore.CertInfo, error) {
+ // На M1 проверяем только доступность сокета и возвращаем
+ // ErrNotImplemented. Это позволяет m2m-core логировать «crypto-service
+ // доступен, но криптография ещё не подключена» отдельно от «сокета
+ // нет совсем».
+ dctx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+ conn, err := c.dialer.DialContext(dctx, "unix", c.socketPath)
+ if err != nil {
+ return m2mcore.CertInfo{}, err
+ }
+ _ = conn.Close()
+ return m2mcore.CertInfo{}, ErrNotImplemented
+}
+
+// Ensure Client реализует m2mcore.CryptoVerifier.
+var _ m2mcore.CryptoVerifier = (*Client)(nil)
diff --git a/internal/cryptocli/client_test.go b/internal/cryptocli/client_test.go
new file mode 100644
index 0000000..567eab0
--- /dev/null
+++ b/internal/cryptocli/client_test.go
@@ -0,0 +1,52 @@
+package cryptocli_test
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
+)
+
+func TestClientReturnsErrNotImplementedWhenSocketReachable(t *testing.T) {
+ dir := t.TempDir()
+ socketPath := filepath.Join(dir, "crypto.sock")
+
+ listener, err := net.Listen("unix", socketPath)
+ if err != nil {
+ t.Fatalf("listen unix: %v", err)
+ }
+ defer listener.Close()
+ defer os.Remove(socketPath)
+
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ _ = conn.Close()
+ }
+ }()
+
+ cli := cryptocli.NewClient(socketPath, cryptocli.WithTimeout(time.Second))
+ _, err = cli.VerifyXMLDSig(context.Background(), []byte(""))
+ if !errors.Is(err, cryptocli.ErrNotImplemented) {
+ t.Errorf("ожидалась ErrNotImplemented, получено %v", err)
+ }
+}
+
+func TestClientReturnsDialErrorWhenSocketMissing(t *testing.T) {
+ cli := cryptocli.NewClient("/nonexistent/crypto.sock", cryptocli.WithTimeout(200*time.Millisecond))
+ _, err := cli.VerifyXMLDSig(context.Background(), []byte("x"))
+ if err == nil {
+ t.Fatal("ожидалась ошибка диалинга на несуществующий сокет")
+ }
+ if errors.Is(err, cryptocli.ErrNotImplemented) {
+ t.Errorf("при отсутствующем сокете не должно быть ErrNotImplemented")
+ }
+}
diff --git a/services/crypto-service/Dockerfile b/services/crypto-service/Dockerfile
new file mode 100644
index 0000000..f490c3a
--- /dev/null
+++ b/services/crypto-service/Dockerfile
@@ -0,0 +1,18 @@
+# Dockerfile для crypto-service.
+# Базовый образ — Liberica JDK 21 (открытый дистрибутив BellSoft с
+# поддержкой работы на территории РФ). Можно заменить на любой
+# OpenJDK 21.
+FROM bellsoft/liberica-openjdk-debian:21 AS build
+WORKDIR /src
+COPY . .
+RUN ./gradlew --no-daemon shadowJar
+
+FROM bellsoft/liberica-openjre-debian:21-slim
+RUN useradd -r -u 1100 -g root bj-crypto && \
+ mkdir -p /run/bj && \
+ chown bj-crypto:root /run/bj
+USER bj-crypto
+COPY --from=build /src/build/libs/crypto-service-*-all.jar /opt/crypto-service.jar
+ENV BJ_CRYPTO_SOCKET=/run/bj/crypto.sock \
+ BJ_CRYPTO_PROVIDER=stub
+ENTRYPOINT ["java", "-jar", "/opt/crypto-service.jar"]
diff --git a/services/crypto-service/README.md b/services/crypto-service/README.md
index fafc470..2442d0d 100644
--- a/services/crypto-service/README.md
+++ b/services/crypto-service/README.md
@@ -1,24 +1,66 @@
-# services/crypto-service — Java + КриптоПро JCP
+# services/crypto-service — крипто-сервис на Java + КриптоПро JCP
-Изолированный сервис для криптографических операций по ГОСТ:
+gRPC-сервис криптографических операций по ГОСТ Р 34.10-2012:
-- проверка подписи входящих квитанций ЭДО, ответов НРД и сообщений
- от брокеров;
-- серверная подпись действий оператора в `admin-ui` (ради соблюдения
- SLA 2 мин — клиентский КриптоПро на АРМ исключён);
-- резервный канал отправки в НРД через WS ONYX напрямую (когда ИШ не
- задействован — упаковка и подпись на нашей стороне);
+- проверка XMLDSig-подписи входящих квитанций НРД, ответов брокеров,
+ заявлений ЛК;
+- серверная подпись действий оператора (admin-ui, SLA 2 мин);
+- резервный канал в НРД через WS ONYX напрямую (когда ИШ не задействован
+ — упаковка и подпись на нашей стороне);
- криптографические проверки целостности эталонов в MinIO.
-Стек: Java 21 (Liberica JDK), Apache Santuario с ГОСТ-патчем, КриптоПро
-JCP. КриптоПро CSP ставится на ВМ. Класс СКЗИ — **КС1** (программная
-защита, без HSM).
+Слушает Unix Domain Socket (`/run/bj/crypto.sock` по умолчанию,
+переопределяется через `BJ_CRYPTO_SOCKET`). gRPC по UDS — без сетевого
+хопа, минимальная задержка.
-Канал общения с Go-ядром — gRPC по Unix Domain Socket (без сетевого хопа,
-минимальная задержка).
+Стек: Java 21 (Liberica JDK), Apache Santuario с ГОСТ-патчем,
+КриптоПро JCP. Класс СКЗИ — **КС1** (программная защита, без HSM).
-Архитектурно сохранена `Provider`-абстракция (Валидата JCP / VipNet и
-прочее) — для тиражирования другим компаниям. На M1 реализован только
-КриптоПро.
+## Состав
-Реализация — задача M1-M2 (заглушка gRPC) и M3-M4 (полный функционал).
+- `proto/crypto.proto` — protobuf-контракт: `VerifyXMLDSig`,
+ `SignXMLDSig`, `Health`. Go-package `cryptopb`, Java-package
+ `ru.zetit.bridgeandjoins.crypto.v1`.
+- `build.gradle.kts` — Gradle сборка с протобуф-плагином и shadowJar.
+- `src/main/java/.../CryptoServer.java` — точка входа, поднимает gRPC
+ на UDS (Netty Epoll). Перед стартом удаляет старый сокет.
+- `src/main/java/.../CryptoServiceImpl.java` — gRPC-биндинг,
+ делегирует на хендлеры.
+- `src/main/java/.../VerifyHandler.java`, `SignHandler.java`,
+ `HealthHandler.java` — реализации операций (на M1 заглушки).
+- `src/main/java/.../KeystoreProvider.java` — provider-абстракция
+ (`cryptopro` | `validata` | `vipnet` | `stub`). Архитектурно
+ сохранена для тиражирования другим компаниям.
+- `Dockerfile` — Liberica JDK 21 → shadowJar → slim-image, запуск под
+ `bj-crypto` (uid 1100).
+
+## Состояние
+
+На M1 — **только скелет**. Реальная криптография подключается, когда
+заказчик предоставит:
+
+- лицензию и jar КриптоПро JCP (положить в `libs/jcp.jar`);
+- доступ к Maven Central через прокси zetit для скачивания
+ grpc-java, protobuf-java, Apache Santuario.
+
+После этого `VerifyHandler` и `SignHandler` реализуются через
+Apache Santuario с ГОСТ-патчем (см. TODO в коде).
+
+## Запуск (после подключения зависимостей)
+
+```bash
+./gradlew shadowJar
+java -jar build/libs/crypto-service-0.1.0-all.jar
+```
+
+Или в docker-compose:
+
+```bash
+podman-compose -f deploy/docker-compose/docker-compose.yml up crypto-service
+```
+
+## Go-клиент
+
+`internal/cryptocli/` — реализация `m2mcore.CryptoVerifier` по UDS.
+На M1 проверяет доступность сокета и возвращает `ErrNotImplemented`
+(чтобы отличать «сокет недоступен» от «криптография не подключена»).
diff --git a/services/crypto-service/build.gradle.kts b/services/crypto-service/build.gradle.kts
new file mode 100644
index 0000000..d435dea
--- /dev/null
+++ b/services/crypto-service/build.gradle.kts
@@ -0,0 +1,83 @@
+// 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).
+//
+// Зависимости (Maven Central, подключаются автоматически):
+// - grpc-java (server + protoc-gen-java)
+// - protobuf-java
+// - apache santuario (XMLDSig)
+// - jcp (КриптоПро JCP) — внешний jar, поставляется заказчиком
+// отдельно вместе с лицензией; положить в libs/.
+//
+// Соберётся, когда Maven Central доступен через прокси zetit
+// (пока ждём админа).
+
+plugins {
+ id("java")
+ id("application")
+ id("com.google.protobuf") version "0.9.4"
+ id("com.github.johnrengelman.shadow") version "8.1.1"
+}
+
+group = "ru.zetit.bridgeandjoins"
+version = "0.1.0"
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+}
+
+repositories {
+ mavenCentral()
+ flatDir { dirs("libs") } // для jcp.jar
+}
+
+dependencies {
+ val grpcVer = "1.62.2"
+ val protobufVer = "3.25.3"
+
+ implementation("io.grpc:grpc-netty-shaded:$grpcVer")
+ implementation("io.grpc:grpc-protobuf:$grpcVer")
+ implementation("io.grpc:grpc-stub:$grpcVer")
+ implementation("com.google.protobuf:protobuf-java:$protobufVer")
+
+ // XMLDSig (с ГОСТ-патчем поставляется в libs/ заказчиком).
+ implementation("org.apache.santuario:xmlsec:3.0.4")
+
+ // КриптоПро JCP — кладётся в services/crypto-service/libs/jcp.jar.
+ // implementation(files("libs/jcp.jar"))
+
+ compileOnly("javax.annotation:javax.annotation-api:1.3.2")
+ implementation("ch.qos.logback:logback-classic:1.5.6")
+
+ testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
+ testImplementation("io.grpc:grpc-testing:$grpcVer")
+}
+
+application {
+ mainClass.set("ru.zetit.bridgeandjoins.crypto.CryptoServer")
+}
+
+protobuf {
+ protoc {
+ artifact = "com.google.protobuf:protoc:3.25.3"
+ }
+ plugins {
+ id("grpc") {
+ artifact = "io.grpc:protoc-gen-grpc-java:1.62.2"
+ }
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.plugins {
+ id("grpc")
+ }
+ }
+ }
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
diff --git a/services/crypto-service/proto/crypto.proto b/services/crypto-service/proto/crypto.proto
new file mode 100644
index 0000000..8cec4a8
--- /dev/null
+++ b/services/crypto-service/proto/crypto.proto
@@ -0,0 +1,78 @@
+syntax = "proto3";
+
+package bridge_and_joins.crypto.v1;
+
+option go_package = "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb;cryptopb";
+option java_package = "ru.zetit.bridgeandjoins.crypto.v1";
+option java_multiple_files = true;
+
+// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
+// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
+service CryptoService {
+ // Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
+ // подписанте: CN, ИНН (если есть), срок действия сертификата.
+ rpc VerifyXMLDSig(VerifyRequest) returns (VerifyResponse);
+
+ // Подпись XML по ГОСТ — для резервного канала WS ONYX и для
+ // серверной подписи действий оператора в admin-ui.
+ rpc SignXMLDSig(SignRequest) returns (SignResponse);
+
+ // Health-check.
+ rpc Health(HealthRequest) returns (HealthResponse);
+}
+
+message VerifyRequest {
+ // Целиком подписанный XML.
+ bytes payload = 1;
+
+ // Профиль ключей и сертификатов: "guest-gost" | "test3-gost" |
+ // "prod-gost" | "guest-rsa" | ... — определяет хранилище и trust store.
+ string profile = 2;
+}
+
+message VerifyResponse {
+ // Прошла ли проверка.
+ bool valid = 1;
+
+ // CN из сертификата подписанта.
+ string signer_cn = 2;
+
+ // ИНН из сертификата (если присутствует в OID 1.2.643.3.131.1.1).
+ string signer_inn = 3;
+
+ // Серийный номер сертификата (hex).
+ string serial = 4;
+
+ // Срок действия сертификата (unix epoch, секунды).
+ int64 not_before = 5;
+ int64 not_after = 6;
+
+ // Тексты ошибок проверки (если valid=false).
+ repeated string errors = 7;
+}
+
+message SignRequest {
+ // Канонизированный XML, который нужно подписать.
+ bytes payload = 1;
+
+ // Алиас ключа в JCP-keystore.
+ string key_alias = 2;
+
+ // Профиль (тот же что у Verify).
+ string profile = 3;
+}
+
+message SignResponse {
+ // Подписанный XML (с детачированной или встроенной подписью —
+ // зависит от профиля).
+ bytes signed_xml = 1;
+}
+
+message HealthRequest {}
+
+message HealthResponse {
+ bool ok = 1;
+ string version = 2;
+ // Активный провайдер криптографии: "cryptopro" | "validata" | "vipnet" | "stub".
+ string provider = 3;
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServer.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServer.java
new file mode 100644
index 0000000..18fa18e
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServer.java
@@ -0,0 +1,62 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+import io.grpc.Server;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.netty.shaded.io.netty.channel.epoll.EpollEventLoopGroup;
+import io.grpc.netty.shaded.io.netty.channel.epoll.EpollServerDomainSocketChannel;
+import io.grpc.netty.shaded.io.netty.channel.unix.DomainSocketAddress;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.logging.Logger;
+
+/**
+ * Точка входа crypto-service.
+ *
+ * Слушает Unix Domain Socket по пути из переменной окружения
+ * BJ_CRYPTO_SOCKET (по умолчанию /run/bj/crypto.sock).
+ * Используется UDS вместо TCP — минимальная задержка и изоляция
+ * от сети.
+ */
+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
+ }
+
+ 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.deleteIfExists(Path.of(socketPath));
+
+ KeystoreProvider keystore = KeystoreProvider.byName(provider);
+ VerifyHandler verifyHandler = new VerifyHandler(keystore);
+ SignHandler signHandler = new SignHandler(keystore);
+ HealthHandler healthHandler = new HealthHandler(provider);
+
+ CryptoServiceImpl service = new CryptoServiceImpl(verifyHandler, signHandler, healthHandler);
+
+ Server server = NettyServerBuilder
+ .forAddress(new DomainSocketAddress(socketPath))
+ .channelType(EpollServerDomainSocketChannel.class)
+ .bossEventLoopGroup(new EpollEventLoopGroup(1))
+ .workerEventLoopGroup(new EpollEventLoopGroup())
+ .addService(service)
+ .build()
+ .start();
+
+ LOG.info("crypto-service: UDS " + socketPath + ", provider=" + provider);
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ LOG.info("crypto-service: graceful shutdown");
+ server.shutdown();
+ }));
+
+ server.awaitTermination();
+ }
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServiceImpl.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServiceImpl.java
new file mode 100644
index 0000000..bbad591
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/CryptoServiceImpl.java
@@ -0,0 +1,54 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+import io.grpc.Status;
+import io.grpc.stub.StreamObserver;
+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.SignRequest;
+import ru.zetit.bridgeandjoins.crypto.v1.SignResponse;
+import ru.zetit.bridgeandjoins.crypto.v1.VerifyRequest;
+import ru.zetit.bridgeandjoins.crypto.v1.VerifyResponse;
+
+/**
+ * gRPC-биндинг: делегирует на хендлеры. Сам по себе не реализует
+ * криптографию.
+ */
+public final class CryptoServiceImpl extends CryptoServiceGrpc.CryptoServiceImplBase {
+
+ private final VerifyHandler verify;
+ private final SignHandler sign;
+ private final HealthHandler health;
+
+ public CryptoServiceImpl(VerifyHandler verify, SignHandler sign, HealthHandler health) {
+ this.verify = verify;
+ this.sign = sign;
+ this.health = health;
+ }
+
+ @Override
+ public void verifyXMLDSig(VerifyRequest request, StreamObserver obs) {
+ try {
+ obs.onNext(verify.handle(request));
+ obs.onCompleted();
+ } catch (Exception e) {
+ obs.onError(Status.INTERNAL.withDescription(e.getMessage()).asException());
+ }
+ }
+
+ @Override
+ public void signXMLDSig(SignRequest request, StreamObserver obs) {
+ try {
+ obs.onNext(sign.handle(request));
+ obs.onCompleted();
+ } catch (Exception e) {
+ obs.onError(Status.INTERNAL.withDescription(e.getMessage()).asException());
+ }
+ }
+
+ @Override
+ public void health(HealthRequest request, StreamObserver obs) {
+ obs.onNext(health.handle(request));
+ obs.onCompleted();
+ }
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/HealthHandler.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/HealthHandler.java
new file mode 100644
index 0000000..2e93e00
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/HealthHandler.java
@@ -0,0 +1,24 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+import ru.zetit.bridgeandjoins.crypto.v1.HealthRequest;
+import ru.zetit.bridgeandjoins.crypto.v1.HealthResponse;
+
+/** Хендлер Health: всегда ok=true с указанием активного провайдера. */
+public final class HealthHandler {
+
+ private static final String VERSION = "0.1.0";
+
+ private final String provider;
+
+ public HealthHandler(String provider) {
+ this.provider = provider;
+ }
+
+ public HealthResponse handle(HealthRequest request) {
+ return HealthResponse.newBuilder()
+ .setOk(true)
+ .setVersion(VERSION)
+ .setProvider(provider)
+ .build();
+ }
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/KeystoreProvider.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/KeystoreProvider.java
new file mode 100644
index 0000000..afe81ea
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/KeystoreProvider.java
@@ -0,0 +1,53 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+/**
+ * Provider-абстракция над хранилищем ключей. Реальные реализации
+ * подключаются вместе с соответствующими jar-ами (КриптоПро JCP,
+ * Валидата JCP, ViPNet). На M1 — Stub.
+ */
+public interface KeystoreProvider {
+
+ /** Уникальное имя провайдера (используется в HealthResponse.provider). */
+ String name();
+
+ /** Возвращает true, если провайдер инициализирован и готов к работе. */
+ boolean ready();
+
+ /**
+ * Подбирает реализацию по имени из переменной окружения
+ * 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();
+ }
+ }
+}
+
+/** Заглушка по умолчанию. Возвращает 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; }
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/SignHandler.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/SignHandler.java
new file mode 100644
index 0000000..5759b79
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/SignHandler.java
@@ -0,0 +1,40 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+import com.google.protobuf.ByteString;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import ru.zetit.bridgeandjoins.crypto.v1.SignRequest;
+import ru.zetit.bridgeandjoins.crypto.v1.SignResponse;
+
+/**
+ * Хендлер подписи XML. На M1 — заглушка, возвращает UNIMPLEMENTED.
+ * Реальная реализация подключается, когда лицензия КриптоПро JCP
+ * будет доступна.
+ */
+public final class SignHandler {
+
+ private final KeystoreProvider keystore;
+
+ public SignHandler(KeystoreProvider keystore) {
+ this.keystore = keystore;
+ }
+
+ public SignResponse handle(SignRequest request) {
+ // TODO(M2): реальная реализация:
+ // 1. Загружаем ключ по key_alias из keystore (через KeystoreProvider).
+ // 2. Канонизуем payload по xml-exc-c14n.
+ // 3. Создаём элемент через Santuario с GOST-алгоритмом.
+ // 4. Возвращаем signed_xml.
+ //
+ // Сейчас — заглушка.
+
+ throw new StatusRuntimeException(Status.UNIMPLEMENTED
+ .withDescription("crypto-service: SignXMLDSig stub — провайдер "
+ + keystore.name() + " не подключён (ждём PR-6/M2 КриптоПро JCP)"));
+ }
+
+ @SuppressWarnings("unused")
+ private SignResponse empty() {
+ return SignResponse.newBuilder().setSignedXml(ByteString.EMPTY).build();
+ }
+}
diff --git a/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/VerifyHandler.java b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/VerifyHandler.java
new file mode 100644
index 0000000..4ee792c
--- /dev/null
+++ b/services/crypto-service/src/main/java/ru/zetit/bridgeandjoins/crypto/VerifyHandler.java
@@ -0,0 +1,38 @@
+package ru.zetit.bridgeandjoins.crypto;
+
+import ru.zetit.bridgeandjoins.crypto.v1.VerifyRequest;
+import ru.zetit.bridgeandjoins.crypto.v1.VerifyResponse;
+
+/**
+ * Хендлер проверки XMLDSig-подписи. На M1 — заглушка, возвращающая
+ * valid=false с пояснением. Реальная реализация (Apache Santuario +
+ * КриптоПро JCP) подключается, когда лицензия JCP станет доступна.
+ */
+public final class VerifyHandler {
+
+ private final KeystoreProvider keystore;
+
+ public VerifyHandler(KeystoreProvider keystore) {
+ this.keystore = keystore;
+ }
+
+ public VerifyResponse handle(VerifyRequest request) {
+ // TODO(M2): реальная реализация через Apache Santuario.
+ //
+ // Шаги при подключении КриптоПро JCP:
+ // 1. Парсим XML, ищем элемент .
+ // 2. Канонизуем по xml-exc-c14n.
+ // 3. Берём сертификат из KeyInfo, проверяем цепочку через
+ // trust store профиля.
+ // 4. Проверяем подпись (alg=GOST34112012-256/-512).
+ // 5. Извлекаем CN, ИНН из subject.
+ //
+ // Сейчас — заглушка.
+
+ return VerifyResponse.newBuilder()
+ .setValid(false)
+ .addErrors("crypto-service: VerifyXMLDSig stub — провайдер " + keystore.name()
+ + " не подключён (ждём PR-6/M2 КриптоПро JCP)")
+ .build();
+ }
+}