From 1d6b290de2f01e87058e28749a64355d7c9337b4 Mon Sep 17 00:00:00 2001
From: debug23win <215046748+debug23win@users.noreply.github.com>
Date: Fri, 2 Oct 2026 01:35:57 +0300
Subject: [PATCH 1/3] Add managed CMS and GOST verification and fix signing
integrity checks
---
.github/workflows/build.yml | 6 +-
.gitignore | 1 +
CHANGELOG.md | 21 +-
LICENSE | 1 +
README.en.md | 48 ++-
README.md | 48 ++-
SignService.sln | 1 +
src/SignService/App.axaml | 1 +
src/SignService/App.axaml.cs | 1 +
src/SignService/Program.cs | 1 +
src/SignService/Properties/AssemblyInfo.cs | 3 +
src/SignService/Services/AppSettings.cs | 13 +
src/SignService/Services/AtomicFile.cs | 35 ++
src/SignService/Services/BerDer.cs | 11 +-
src/SignService/Services/CadesAttributes.cs | 1 +
.../Services/CertificateProvider.cs | 1 +
.../Services/CertificateValidator.cs | 95 +++++
src/SignService/Services/CertificateVault.cs | 1 +
src/SignService/Services/CmsExtractor.cs | 4 +-
src/SignService/Services/CmsMerger.cs | 71 +++-
src/SignService/Services/DocumentSigner.cs | 41 +-
src/SignService/Services/NativeSign.cs | 1 +
src/SignService/Services/PdfStamper.cs | 1 +
.../Services/PowerOfAttorneyService.cs | 184 ++++++---
src/SignService/Services/RevocationChecker.cs | 210 ++++++++++
.../Services/SavedCertificateInfo.cs | 1 +
.../Services/SignatureVerification.cs | 98 +++++
src/SignService/Services/SignatureVerifier.cs | 249 ++++++++++++
src/SignService/Services/Streebog.cs | 1 +
src/SignService/Services/TimestampClient.cs | 50 ++-
src/SignService/Services/UpdateService.cs | 1 +
src/SignService/SignService.csproj | 3 +-
src/SignService/ViewModels/CertificateItem.cs | 1 +
.../ViewModels/MainWindowViewModel.cs | 39 +-
src/SignService/ViewModels/SignFileItem.cs | 1 +
src/SignService/Views/AboutDialog.axaml | 1 +
src/SignService/Views/AboutDialog.axaml.cs | 1 +
src/SignService/Views/ConfirmDialog.axaml | 1 +
src/SignService/Views/ConfirmDialog.axaml.cs | 1 +
src/SignService/Views/MainWindow.axaml | 2 +
src/SignService/Views/MainWindow.axaml.cs | 1 +
src/SignService/Views/PasswordDialog.axaml | 1 +
src/SignService/Views/PasswordDialog.axaml.cs | 1 +
.../Views/VerificationDialog.axaml | 40 ++
.../Views/VerificationDialog.axaml.cs | 141 +++++++
src/SignService/app.manifest | 1 +
tests/SignService.Tests/Program.cs | 9 +-
.../SignService.Tests.csproj | 1 +
tests/SignService.Tests/VerificationTests.cs | 382 ++++++++++++++++++
tests/SignService.Tests/streebog_vectors.json | 2 +-
50 files changed, 1690 insertions(+), 140 deletions(-)
create mode 100644 src/SignService/Properties/AssemblyInfo.cs
create mode 100644 src/SignService/Services/AtomicFile.cs
create mode 100644 src/SignService/Services/CertificateValidator.cs
create mode 100644 src/SignService/Services/RevocationChecker.cs
create mode 100644 src/SignService/Services/SignatureVerification.cs
create mode 100644 src/SignService/Services/SignatureVerifier.cs
create mode 100644 src/SignService/Views/VerificationDialog.axaml
create mode 100644 src/SignService/Views/VerificationDialog.axaml.cs
create mode 100644 tests/SignService.Tests/VerificationTests.cs
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8f660c4..0317fdf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,6 +4,7 @@ on:
push:
branches: ["main", "claude/**"]
tags: ["v*"]
+ pull_request:
workflow_dispatch:
inputs:
release_tag:
@@ -12,7 +13,7 @@ on:
default: ""
permissions:
- contents: write # публикация GitHub Release на тег v*
+ contents: read
jobs:
tests:
@@ -30,7 +31,10 @@ jobs:
build-windows:
name: Windows x64 exe
+ needs: tests
runs-on: windows-latest
+ permissions:
+ contents: write # publication on a version tag or an explicitly requested release
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index 6f5d3e4..680f71c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ obj/
.idea/
artifacts/
publish/
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18a2b91..9e9f97b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,14 +2,31 @@
## v1.1.0 — не выпущена
+- **Проверка ЭЦП без КриптоПро**: пункт «Инструменты → Проверить ЭЦП…»,
+ CMS/CAdES с ГОСТ-2001, ГОСТ-2012 256/512, RSA и ECDSA; отчёт по каждому
+ подписанту с экспортом в TXT и отменой проверки.
+- Раздельные результаты для документа, значения подписи, привязки сертификата,
+ доверия, отзыва и TSA. Цепочки сертификатов проверяются без криптопровайдера;
+ вложенный корень автоматически не считается доверенным. Поддерживаются
+ подписанные CRL/OCSP и сетевые запросы по выбору; отсутствие подтверждений
+ отображается явно. TSA проверяется вместе с привязкой к подписи и запросу.
+- Исправлены проверки объединения и МЧД: совпадение хеша больше не пропускает
+ повреждённое значение подписи; исключённый подписант не оставляет старый
+ вложенный документ, выбранный режим откреплённой подписи сохраняется.
+- МЧД проверяется по текущим XML/SIG и копируется из проверенного снимка;
+ конфликт имён файлов и противоречивые ИНН/СНИЛС останавливают операцию.
+ Файлы подписи сохраняются через временный файл и атомарную замену.
+- Ограничена вложенность ASN.1 и исправлен контроль переполнения длины.
+ Тесты запускаются для pull request; сборка Windows зависит от их успеха.
+
- **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается
ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно
без подписи — после подписания и со всеми подписантами итоговой подписи
(по блоку данных на каждого). Прежний режим «подписывать копию со
штампом» остался отдельной галочкой с предупреждением.
- **Доверенность МЧД** (кнопка «Добавить доверенность…», как в Контуре):
- загрузка XML (EMCHD_1) и подписи руководителя, проверка подписи по хешу
- (для ГОСТ — Стрибогом), срока действия и соответствия представителя
+ загрузка XML (EMCHD_1) и подписи руководителя, криптографическая проверка,
+ проверка срока действия и соответствия представителя
сертификату по ИНН/СНИЛС; при подписании файлы МЧД копируются рядом
с документом, номер МЧД добавляется в штамп на PDF.
diff --git a/LICENSE b/LICENSE
index 2c154ee..b9869fd 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
diff --git a/README.en.md b/README.en.md
index 8dc1e5d..d6d7e26 100644
--- a/README.en.md
+++ b/README.en.md
@@ -43,14 +43,19 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
a signature file onto the window;
- re-signing with the same certificate **replaces** your previous signature
instead of duplicating it;
-- every merged signature is **verified against the document hash**
- (messageDigest; Streebog for GOST): signatures made over a different file
- or an older revision are excluded automatically, reporting the signer name;
+- every merged signature is **cryptographically verified**, including the
+ document digest, signature value and CAdES certificate binding. Damaged
+ signatures and signatures over another revision are excluded; unverifiable
+ signers are explicitly reported;
- input signatures are accepted in DER/BER (including indefinite lengths and
trailing bytes) and base64/PEM.
### Tools (no signature created)
+- **Verify signature…** — per-signer CMS/CAdES verification for GOST-2001,
+ GOST-2012 256/512, RSA and ECDSA without CryptoPro. Select the original for
+ a detached signature; attached signatures use the embedded document.
+ Copy or export the detailed report to TXT;
- **Merge .sig…** — combine several signature files into one containing all
signers (attached containers are accepted; the embedded document is kept);
- **Extract from .sig…** — pull out of a container: the embedded document
@@ -65,8 +70,8 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- the "Add power of attorney…" button works like Kontur: pick the MChD XML
(EMCHD_1 format) and the head's signature (.sig; a neighbouring
`name.xml.sig` is picked up automatically);
-- the app verifies: the head's signature matches the MChD file (by hash,
- Streebog for GOST), the validity period has not expired, and the
+- the app verifies the head's signature using the certificate public key,
+ checks the validity period and matches the
representative in the MChD matches the selected certificate by INN/SNILS;
- when signing, the MChD files (XML + .sig) are copied next to the signed
document (the MChD is NOT embedded into the CMS signature — Kontur does
@@ -108,11 +113,37 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- **auto-update**: new releases are checked on GitHub Releases at startup
(can be disabled) and installed in one click from the About window (Windows).
+### Trust, revocation and timestamps
+
+The report separates document integrity, signature value, certificate binding,
+certificate trust, revocation and TSA results. An embedded root never creates
+trust: anchors come from OS root stores and explicitly selected CA certificates.
+BouncyCastle verifies certificate-chain, CRL and OCSP signatures without a CSP.
+
+Verification is offline by default. It uses embedded CRLs and selected `.crl`/
+`.ocsp` files; HTTP(S) CRL/OCSP requests require the network checkbox. Revocation
+is checked for every chain certificate except the trust anchor. Missing, stale,
+unverifiable or partial evidence (including delta/indirect CRLs) gives an unknown
+status. Missing intermediate certificates must be embedded in the CMS or in OS
+intermediate stores.
+
+The signer's `signing-time` does not prove when a signature was created.
+Historical certificate validity is assessed only using a TSA timestamp whose
+imprint, signature, trust and required revocation checks passed. TSA responses
+also must match the request and nonce.
+
+MChD verification re-reads current files, verifies and copies the same XML/SIG
+snapshot. Conflicting destination files stop signing before the signature is
+saved. Cryptographic verification does not establish the head's authority,
+the scope of delegated powers or MChD registry revocation; missing assurances
+are reported as warnings.
+
## Requirements
- Windows 10/11 — the self-contained exe from the Releases page;
-- for GOST signatures — **CryptoPro CSP** installed with a personal
+- to **create** GOST signatures — **CryptoPro CSP** installed with a personal
certificate;
+- verifying and merging GOST signatures requires **no CryptoPro** on Windows/Linux/macOS;
- building from source: .NET 8 SDK (Windows/Linux/macOS; outside Windows
signing is limited to platform algorithms — RSA/ECDSA; GOST requires
the CSP on Windows).
@@ -125,6 +156,11 @@ dotnet run --project src/SignService
dotnet run --project tests/SignService.Tests -c Release # tests
```
+On a personal workstation append `-- --skip-user-store` to the test command to
+skip scenarios that change user certificate stores or application data. CI runs
+the full suite in an isolated runner. Managed GOST, CRL/OCSP and TSA verification
+tests run without CryptoPro or OpenSSL.
+
Publishing the self-contained exe:
```bash
diff --git a/README.md b/README.md
index 9878f0d..13611db 100644
--- a/README.md
+++ b/README.md
@@ -37,14 +37,18 @@
с существующей `имя.sig` рядом (отключается), кнопкой «+.sig» у файла
или перетаскиванием файла подписи в окно;
- повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует;
-- каждая объединяемая подпись **проверяется по хешу документа** (messageDigest;
- для ГОСТ — Стрибогом): подписи под другим файлом или прежней версией
- исключаются автоматически с указанием подписанта;
+- каждая объединяемая подпись **проверяется криптографически**: хеш документа,
+ значение подписи и привязка сертификата CAdES. Повреждённые подписи и подписи
+ под другой версией исключаются с указанием подписанта; непроверенные явно отмечаются;
- входные подписи принимаются в DER/BER (включая неопределённые длины
и «хвосты» файлов) и base64/PEM.
### Инструменты (без создания подписи)
+- **Проверить ЭЦП…** — проверка каждого подписанта CMS/CAdES: ГОСТ-2001,
+ ГОСТ-2012 256/512, RSA и ECDSA без КриптоПро. Для откреплённой подписи
+ выберите исходный документ; для прикреплённой он берётся из контейнера.
+ Отчёт можно скопировать или сохранить в TXT;
- **Объединить .sig…** — несколько файлов подписей склеиваются в один со всеми
подписантами (принимаются и прикреплённые контейнеры — документ сохраняется);
- **Извлечь из .sig…** — из криптоконтейнера достаются: вложенный документ
@@ -59,8 +63,8 @@
- кнопка «Добавить доверенность…» — как в Контуре: выбираются файл МЧД
(XML, формат EMCHD_1) и подпись руководителя (.sig; `имя.xml.sig` рядом
подхватывается автоматически);
-- программа проверяет: подпись руководителя соответствует файлу МЧД
- (по хешу, для ГОСТ — Стрибогом), срок действия не истёк, представитель
+- программа проверяет подпись руководителя открытым ключом сертификата,
+ срок действия и совпадение представителя
в МЧД совпадает с владельцем выбранного сертификата по ИНН/СНИЛС;
- при подписании файлы МЧД (XML + .sig) копируются рядом с подписанным
документом (в CMS-подпись МЧД не вкладывается — так же поступает Контур),
@@ -104,10 +108,37 @@
- **автообновление**: проверка новых версий на GitHub Releases при запуске
(отключается) и обновление в один клик из окна «О программе» (Windows).
+### Доверие, отзыв и время подписи
+
+Отчёт разделяет соответствие документу, значение подписи, привязку сертификата,
+цепочку доверия, статус отзыва и метку TSA. Корректная подпись сама по себе
+не подтверждает доверие к сертификату. Вложенный корень не становится доверенным:
+используются корни из хранилища ОС и явно выбранные в окне проверки сертификаты УЦ.
+Подписи сертификатов цепочки, CRL и OCSP проверяются управляемой библиотекой BouncyCastle.
+
+По умолчанию проверка работает без сети. Для отзыва используются вложенные CRL
+и выбранные файлы `.crl`/`.ocsp`. Сетевые запросы к HTTP(S) службам CRL/OCSP УЦ
+включаются отдельной галочкой. Просроченные, неподписанные, неполные (в том числе
+delta/indirect CRL) или недоступные доказательства дают неопределённый статус,
+а не «сертификат действителен»; данные об отзыве проверяются для всех сертификатов
+цепочки, кроме доверенного корня. Недостающие промежуточные сертификаты
+должны присутствовать в CMS или хранилище ОС.
+
+`signing-time` — время, заявленное самим подписантом. Исторический срок сертификата
+оценивается по TSA только после проверки хеша, подписи, доверия и требуемого статуса
+отзыва TSA. При получении метки проверяются также nonce и соответствие запросу.
+
+МЧД перечитывается перед подписанием, проверяется и копируется как один снимок XML + SIG.
+Конфликт с другим файлом МЧД останавливает операцию до сохранения подписи.
+Полномочия руководителя, содержание полномочий и отзыв самой МЧД в реестре
+не устанавливаются одной криптографической проверкой; при отсутствии подтверждений
+программа показывает предупреждение.
+
## Требования
- Windows 10/11 — готовый exe со страницы Releases (самодостаточный);
-- для ГОСТ-подписей — установленный **КриптоПро CSP** и личный сертификат;
+- для **создания** ГОСТ-подписей — установленный **КриптоПро CSP** и личный сертификат;
+- проверка и объединение ГОСТ-подписей работают **без КриптоПро** на Windows/Linux/macOS;
- сборка из исходников: .NET 8 SDK (Windows/Linux/macOS; вне Windows
подписание доступно алгоритмами платформы — RSA/ECDSA, ГОСТ — только
через CSP на Windows).
@@ -120,6 +151,11 @@ dotnet run --project src/SignService
dotnet run --project tests/SignService.Tests -c Release # тесты
```
+На рабочем компьютере используйте `-- --skip-user-store` в конце команды тестов,
+чтобы пропустить сценарии, изменяющие личное хранилище сертификатов и данные приложения.
+Полный набор запускается в изолированном CI. Управляемые тесты проверки ГОСТ, CRL/OCSP
+и TSA выполняются без КриптоПро и OpenSSL.
+
Публикация самодостаточного exe:
```bash
diff --git a/SignService.sln b/SignService.sln
index df07912..58c27d9 100644
--- a/SignService.sln
+++ b/SignService.sln
@@ -30,3 +30,4 @@ Global
{8890B6DC-E903-4D7E-AF79-9215F9BC39B0} = {1D19B574-1DCC-45AE-991D-32715C73EA9B}
EndGlobalSection
EndGlobal
+
diff --git a/src/SignService/App.axaml b/src/SignService/App.axaml
index 8d771b0..98e3540 100644
--- a/src/SignService/App.axaml
+++ b/src/SignService/App.axaml
@@ -7,3 +7,4 @@
+
diff --git a/src/SignService/App.axaml.cs b/src/SignService/App.axaml.cs
index 1057ae5..b8bbcc2 100644
--- a/src/SignService/App.axaml.cs
+++ b/src/SignService/App.axaml.cs
@@ -34,3 +34,4 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}
}
+
diff --git a/src/SignService/Program.cs b/src/SignService/Program.cs
index aaad3a9..02e4f9b 100644
--- a/src/SignService/Program.cs
+++ b/src/SignService/Program.cs
@@ -18,3 +18,4 @@ public static AppBuilder BuildAvaloniaApp()
.WithInterFont()
.LogToTrace();
}
+
diff --git a/src/SignService/Properties/AssemblyInfo.cs b/src/SignService/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..6a26d2b
--- /dev/null
+++ b/src/SignService/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("SignService.Tests")]
diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs
index 9e54390..b387740 100644
--- a/src/SignService/Services/AppSettings.cs
+++ b/src/SignService/Services/AppSettings.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
+using System.Linq;
namespace SignService.Services;
@@ -66,6 +67,18 @@ public class AppSettings
/// Путь к подписи руководителя (.sig) для МЧД.
public string? PoaSigPath { get; set; }
+ /// Дополнительные явно доверенные корни; хранилище ОС не изменяется.
+ public List VerificationTrustedRootPaths { get; set; } = new();
+
+ /// Разрешение запросов CRL/OCSP при проверке ЭЦП.
+ public bool VerificationAllowNetwork { get; set; }
+
+ public VerificationOptions CreateVerificationOptions(bool allowNetwork = false) => new()
+ {
+ TrustedRoots = VerificationTrustedRootPaths.Select(File.ReadAllBytes).ToArray(),
+ AllowNetwork = allowNetwork,
+ };
+
public static AppSettings Load()
{
try
diff --git a/src/SignService/Services/AtomicFile.cs b/src/SignService/Services/AtomicFile.cs
new file mode 100644
index 0000000..a5e3050
--- /dev/null
+++ b/src/SignService/Services/AtomicFile.cs
@@ -0,0 +1,35 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace SignService.Services;
+
+internal static class AtomicFile
+{
+ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = true,
+ CancellationToken cancellationToken = default)
+ {
+ var fullPath = Path.GetFullPath(path);
+ var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, ".signservice-" + Guid.NewGuid().ToString("N") + ".tmp");
+ try
+ {
+ await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None,
+ 8192, FileOptions.Asynchronous | FileOptions.WriteThrough))
+ {
+ await stream.WriteAsync(bytes, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ stream.Flush(flushToDisk: true);
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Move(temporary, fullPath, overwrite);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+
+ public static void Write(string path, byte[] bytes, bool overwrite = false) =>
+ WriteAsync(path, bytes, overwrite).GetAwaiter().GetResult();
+}
diff --git a/src/SignService/Services/BerDer.cs b/src/SignService/Services/BerDer.cs
index d8118ff..cbd8e69 100644
--- a/src/SignService/Services/BerDer.cs
+++ b/src/SignService/Services/BerDer.cs
@@ -20,13 +20,14 @@ internal static class BerDer
public static byte[] ToDefinite(ReadOnlySpan input)
{
var output = new MemoryStream();
- ConvertValue(input, output);
+ ConvertValue(input, output, 0);
return output.ToArray();
}
// Перекодирует один TLV из s в output; возвращает число прочитанных байтов.
- private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
+ private static int ConvertValue(ReadOnlySpan s, MemoryStream output, int depth)
{
+ if (depth > 128) throw new AsnContentException("Превышена допустимая вложенность ASN.1.");
if (s.Length < 2)
throw new AsnContentException("Обрезанные ASN.1-данные.");
@@ -66,7 +67,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
break;
}
- p += ConvertValue(s[p..], inner);
+ p += ConvertValue(s[p..], inner, depth + 1);
}
content = inner.ToArray();
@@ -88,7 +89,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
length = (length << 8) | s[p++];
}
- if (p + length > s.Length)
+ if (length < 0 || length > s.Length - p)
throw new AsnContentException("Длина ASN.1-значения выходит за пределы данных.");
if (constructed)
@@ -97,7 +98,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
var children = s.Slice(p, length);
var q = 0;
while (q < children.Length)
- q += ConvertValue(children[q..], inner);
+ q += ConvertValue(children[q..], inner, depth + 1);
content = inner.ToArray();
}
else
diff --git a/src/SignService/Services/CadesAttributes.cs b/src/SignService/Services/CadesAttributes.cs
index 5a49439..e6a755f 100644
--- a/src/SignService/Services/CadesAttributes.cs
+++ b/src/SignService/Services/CadesAttributes.cs
@@ -79,3 +79,4 @@ private static (string? HashOid, byte[] Hash) HashCertificate(X509Certificate2 c
_ => (null, SHA256.HashData(certificate.RawData)),
};
}
+
diff --git a/src/SignService/Services/CertificateProvider.cs b/src/SignService/Services/CertificateProvider.cs
index aa546a7..5a8702e 100644
--- a/src/SignService/Services/CertificateProvider.cs
+++ b/src/SignService/Services/CertificateProvider.cs
@@ -49,3 +49,4 @@ public static string GetIssuerName(X509Certificate2 certificate)
return string.IsNullOrWhiteSpace(cn) ? certificate.Issuer : cn;
}
}
+
diff --git a/src/SignService/Services/CertificateValidator.cs b/src/SignService/Services/CertificateValidator.cs
new file mode 100644
index 0000000..7239b29
--- /dev/null
+++ b/src/SignService/Services/CertificateValidator.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using Org.BouncyCastle.Pkix;
+using Org.BouncyCastle.Security.Certificates;
+using Org.BouncyCastle.Utilities.Collections;
+using Org.BouncyCastle.X509;
+using Org.BouncyCastle.X509.Store;
+using Certificate = Org.BouncyCastle.X509.X509Certificate;
+
+namespace SignService.Services;
+
+internal sealed record CertificateValidation(VerificationCheck Trust, VerificationCheck Revocation);
+
+internal static class CertificateValidator
+{
+ public static CertificateValidation Validate(Certificate certificate, IReadOnlyList embedded,
+ IReadOnlyList crls, VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var notChecked = VerificationCheck.NotChecked("Проверка отключена.");
+ if (!options.CheckCertificateTrust && !options.CheckRevocation)
+ return new CertificateValidation(notChecked, notChecked);
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ certificate.CheckValidity(options.ValidationTime.UtcDateTime);
+ var usage = certificate.GetKeyUsage();
+ if (usage is not null && !usage[0] && !usage[1])
+ return new CertificateValidation(VerificationCheck.Invalid("Сертификат не разрешает подписание."),
+ VerificationCheck.Unknown("Нет допустимой цепочки для проверки отзыва."));
+ var parser = new X509CertificateParser();
+ var roots = options.TrustedRoots.Select(parser.ReadCertificate).ToList();
+ var candidates = embedded.Concat(options.ExtraCertificates.Select(parser.ReadCertificate)).ToList();
+ if (options.UseSystemTrustStore)
+ foreach (var location in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine })
+ {
+ ReadStore(StoreName.Root, location, roots);
+ ReadStore(StoreName.CertificateAuthority, location, candidates);
+ }
+ // A root supplied inside a CMS is an intermediate candidate, never a trust anchor.
+ roots = roots.Distinct().ToList();
+ if (roots.Count == 0)
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Unknown("Нет доверенного корневого сертификата. Вложенный корень не создаёт доверия.") : notChecked,
+ options.CheckRevocation ? VerificationCheck.Unknown("Без цепочки до доверенного корня отзыв не подтверждён.") : notChecked);
+ var parameters = new PkixBuilderParameters(roots.Select(c => new TrustAnchor(c, null)).ToHashSet(),
+ new X509CertStoreSelector { Certificate = certificate })
+ {
+ Date = options.ValidationTime.UtcDateTime, IsRevocationEnabled = false, MaxPathLength = 8,
+ };
+ parameters.AddStoreCert(CollectionUtilities.CreateStore(candidates.Append(certificate).Concat(roots)));
+ var path = new PkixCertPathBuilder().Build(parameters);
+ var root = path.TrustAnchor.TrustedCert;
+ root.CheckValidity(options.ValidationTime.UtcDateTime);
+ var chain = path.CertPath.Certificates.ToList();
+ if (chain.Count == 0 || !chain[^1].Equals(root)) chain.Add(root);
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Valid($"Цепочка до доверенного корня проверена на {options.ValidationTime:dd.MM.yyyy HH:mm:ss zzz}.") : notChecked,
+ options.CheckRevocation ? RevocationChecker.Check(chain, crls, options, cancellationToken) : notChecked);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (CertificateExpiredException)
+ {
+ return new CertificateValidation(VerificationCheck.Invalid("Срок сертификата истёк на момент проверки."),
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не проверен: сертификат просрочен.") : notChecked);
+ }
+ catch (CertificateNotYetValidException)
+ {
+ return new CertificateValidation(VerificationCheck.Invalid("Сертификат ещё не действовал на момент проверки."),
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не проверен: сертификат ещё не действовал.") : notChecked);
+ }
+ catch (Exception e)
+ {
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Unknown("Не удалось построить допустимую цепочку до доверенного корня: " + e.Message) : notChecked,
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не подтверждён без допустимой цепочки.") : notChecked);
+ }
+ }
+
+ private static void ReadStore(StoreName name, StoreLocation location, List destination)
+ {
+ try
+ {
+ using var store = new X509Store(name, location);
+ store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
+ foreach (var cert in store.Certificates)
+ using (cert)
+ try { destination.Add(new X509CertificateParser().ReadCertificate(cert.RawData)); }
+ catch (Exception) { /* Skip unreadable entries; they cannot establish trust. */ }
+ }
+ catch (Exception) { /* A platform may not expose both store locations. */ }
+ }
+}
diff --git a/src/SignService/Services/CertificateVault.cs b/src/SignService/Services/CertificateVault.cs
index 8029c4b..ab2d779 100644
--- a/src/SignService/Services/CertificateVault.cs
+++ b/src/SignService/Services/CertificateVault.cs
@@ -206,3 +206,4 @@ public void Delete(SavedCertificateInfo info, AppSettings settings)
settings.Save();
}
}
+
diff --git a/src/SignService/Services/CmsExtractor.cs b/src/SignService/Services/CmsExtractor.cs
index 9f698ab..711a36c 100644
--- a/src/SignService/Services/CmsExtractor.cs
+++ b/src/SignService/Services/CmsExtractor.cs
@@ -102,7 +102,7 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths)
: firstName;
var outputPath = UniquePath(directory, $"{baseName} (объединённая).sig", paths);
- File.WriteAllBytes(outputPath, merged);
+ AtomicFile.Write(outputPath, merged);
return new MergeFilesResult(
outputPath, info.SignerNames.Count, info.HasContent, documentNote, excluded, unverified);
@@ -143,7 +143,7 @@ public static BuildContainerResult BuildContainer(
directory,
Path.GetFileName(documentPath) + " (контейнер).sig",
paths.Append(documentPath).ToList());
- File.WriteAllBytes(outputPath, container);
+ AtomicFile.Write(outputPath, container);
return new BuildContainerResult(
outputPath, merged.SignerCount, merged.ExcludedSigners, merged.UnverifiedSigners);
diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs
index eaffbaa..dd0a750 100644
--- a/src/SignService/Services/CmsMerger.cs
+++ b/src/SignService/Services/CmsMerger.cs
@@ -40,12 +40,10 @@ public sealed record MergeResult(
IReadOnlyList UnverifiedSigners);
///
- /// Объединяет подписи с проверкой соответствия каждой из них документу:
- /// у подписанта из подписанных атрибутов берётся messageDigest и сравнивается
- /// с хешем документа (ГОСТ Р 34.11-2012 — своей реализацией Стрибога, SHA-1/256/384/512 —
- /// штатно). Подписи, сделанные под другим файлом или прежней версией документа,
- /// ИСКЛЮЧАЮТСЯ из результата (их список — в ExcludedSigners). Подписи, чей алгоритм
- /// хеша проверить нечем, сохраняются и перечисляются в UnverifiedSigners.
+ /// Проверяет хеш документа, значение подписи и привязку сертификата каждого
+ /// подписанта. Повреждённые подписи и подписи другой версии исключаются.
+ /// Подписи с неизвестным алгоритмом или без сертификата сохраняются только
+ /// с явным указанием в UnverifiedSigners. Доверие УЦ проверяется отдельно.
///
public static MergeResult MergeForDocument(IReadOnlyList signatures, byte[] document)
{
@@ -56,21 +54,35 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt
// Справочник сертификатов всех входов — для имени подписанта в отчёте.
var certIndex = BuildCertIndex(parsed);
- var hashCache = new Dictionary();
var excluded = new List();
var unverified = new List();
+ var contentTypes = parsed.Select(p => new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER)
+ .ReadSequence().ReadObjectIdentifier()).Distinct().ToList();
+ if (contentTypes.Count != 1)
+ throw new InvalidOperationException("Нельзя объединить подписи с разными типами содержимого CMS.");
+ var allCertificates = DedupeBytes(parsed.SelectMany(p => p.Certificates));
+
foreach (var p in parsed)
{
for (var i = p.Signers.Count - 1; i >= 0; i--)
{
var (_, der) = p.Signers[i];
- var check = CheckSignerAgainstDocument(der, document, hashCache);
- if (check == SignerDocMatch.Match)
+ var single = new ParsedSignedData { Version = p.Version,
+ EncapContentInfo = p.EncapContentInfo, HasContent = p.HasContent };
+ single.Signers.Add(p.Signers[i]);
+ single.DigestAlgorithms.AddRange(p.DigestAlgorithms);
+ single.Certificates.AddRange(allCertificates);
+ var result = SignatureVerifier.Verify(BuildMerged(new List { single }),
+ document, VerificationOptions.CryptographyOnly);
+ var check = result.Signers.SingleOrDefault();
+ if (check?.CryptographicallyValid == true)
continue;
var name = SignerDisplayName(der, certIndex);
- if (check == SignerDocMatch.Mismatch)
+ if (result.Container.State == VerificationState.Invalid || check is not null &&
+ (check.Document.State == VerificationState.Invalid || check.Signature.State == VerificationState.Invalid
+ || check.CertificateBinding.State == VerificationState.Invalid))
{
excluded.Add(name);
p.Signers.RemoveAt(i);
@@ -84,7 +96,19 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt
if (parsed.All(p => p.Signers.Count == 0))
throw new InvalidOperationException(
- "Все объединяемые подписи не соответствуют текущему содержимому документа.");
+ "Все объединяемые подписи повреждены или не соответствуют текущему содержимому документа.");
+
+ // Excluded signers must not leave their old embedded document in the output.
+ foreach (var p in parsed.Where(p => p.HasContent))
+ {
+ var writer = new AsnWriter(AsnEncodingRules.DER);
+ using (writer.PushSequence())
+ {
+ writer.WriteObjectIdentifier(contentTypes[0]);
+ using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0))) writer.WriteOctetString(document);
+ }
+ p.EncapContentInfo = writer.Encode();
+ }
var merged = BuildMerged(parsed);
return new MergeResult(
@@ -109,7 +133,21 @@ public static byte[] Merge(IReadOnlyList signatures)
private static byte[] BuildMerged(List parsed)
{
-
+ var types = parsed.Select(p => new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER)
+ .ReadSequence().ReadObjectIdentifier()).Distinct().Count();
+ if (types != 1) throw new InvalidOperationException("Типы содержимого CMS различаются.");
+ var attached = parsed.Where(p => p.HasContent).ToList();
+ if (attached.Select(p => Convert.ToBase64String(p.EncapContentInfo)).Distinct().Count() > 1)
+ {
+ var contents = attached.Select(p =>
+ {
+ var encap = new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER).ReadSequence();
+ encap.ReadObjectIdentifier();
+ return ReadOctetStringData(encap.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)));
+ }).ToList();
+ if (contents.Skip(1).Any(c => !c.AsSpan().SequenceEqual(contents[0])))
+ throw new InvalidOperationException("Прикреплённые подписи содержат разные документы.");
+ }
var version = parsed.Max(p => p.Version);
var digestAlgorithms = DedupeBytes(parsed.SelectMany(p => p.DigestAlgorithms));
var certificates = DedupeBytes(parsed.SelectMany(p => p.Certificates));
@@ -510,6 +548,7 @@ public static DocMatch CheckAgainstDocument(byte[] signature, byte[] document)
var parsed = Parse(Normalize(signature));
var cache = new Dictionary();
var anyMatch = false;
+ var anyUnknown = false;
foreach (var (_, der) in parsed.Signers)
{
switch (CheckSignerAgainstDocument(der, document, cache))
@@ -519,10 +558,13 @@ public static DocMatch CheckAgainstDocument(byte[] signature, byte[] document)
case SignerDocMatch.Match:
anyMatch = true;
break;
+ case SignerDocMatch.Unknown:
+ anyUnknown = true;
+ break;
}
}
- return anyMatch ? DocMatch.Match : DocMatch.Unknown;
+ return anyMatch && !anyUnknown ? DocMatch.Match : DocMatch.Unknown;
}
private enum SignerDocMatch
@@ -597,7 +639,8 @@ private static (string DigestOid, byte[]? MessageDigest) InspectSigner(byte[] si
"2.16.840.1.101.3.4.2.2" => SHA384.HashData(document),
"2.16.840.1.101.3.4.2.3" => SHA512.HashData(document),
"1.3.14.3.2.26" => SHA1.HashData(document),
- _ => null, // ГОСТ 34.11-94 и прочее — не проверяем
+ "1.2.643.2.2.9" => Org.BouncyCastle.Security.DigestUtilities.CalculateDigest(digestOid, document),
+ _ => null,
};
// «Кому выдан» (CN) сертификата подписанта по его SignerIdentifier, либо номер-заглушка.
diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs
index 365315d..2c7500b 100644
--- a/src/SignService/Services/DocumentSigner.cs
+++ b/src/SignService/Services/DocumentSigner.cs
@@ -60,6 +60,8 @@ public sealed record SignOptions
/// подписанного документа (как это делает Контур), номер попадает в PDF-штамп.
///
public PowerOfAttorneyService.PoaInfo? PowerOfAttorney { get; init; }
+
+ public VerificationOptions? PoaVerificationOptions { get; init; }
}
///
@@ -112,6 +114,12 @@ public async Task SignFileAsync(
SignOptions options,
CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+ var poaPackage = options.PowerOfAttorney is { } selectedPoa
+ ? PowerOfAttorneyService.Prepare(selectedPoa, certificate, options.PoaVerificationOptions) : null;
+ if (poaPackage?.Check.State == PowerOfAttorneyService.CheckState.Error)
+ throw new InvalidOperationException(poaPackage.Check.Message);
+ if (poaPackage is not null) PowerOfAttorneyService.CheckCopyTargets(poaPackage, filePath);
// Режим «подписывать копию со штампом»: штамп меняет содержимое PDF,
// поэтому копия создаётся ДО подписания и подписывается именно она.
// В режиме «копия отдельно» подписывается ОРИГИНАЛ, а штампованная копия
@@ -124,7 +132,7 @@ public async Task SignFileAsync(
{
targetPath = PdfStamper.CreateStampedCopy(
filePath, certificate, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ poaPackage?.Info.Number);
}
catch (Exception e)
{
@@ -163,9 +171,10 @@ public async Task SignFileAsync(
int signerCount;
IReadOnlyList excluded;
IReadOnlyList unverified;
+ byte[] output;
if (inputs.Count == 1)
{
- await File.WriteAllBytesAsync(signaturePath, own, cancellationToken);
+ output = own;
signerCount = 1;
excluded = Array.Empty();
unverified = Array.Empty();
@@ -175,14 +184,18 @@ public async Task SignFileAsync(
// Объединение с проверкой: подписи под другим файлом или прежней версией
// документа исключаются — иначе портал отклонит весь контейнер.
var merged = CmsMerger.MergeForDocument(inputs, data);
- await File.WriteAllBytesAsync(signaturePath, merged.Signature, cancellationToken);
+ output = options.Detached ? CmsMerger.ConvertToDetached(merged.Signature)
+ : CmsMerger.AttachContent(merged.Signature, data);
signerCount = merged.SignerCount;
excluded = merged.ExcludedSigners;
unverified = merged.UnverifiedSigners;
}
- if (options.PowerOfAttorney is { } poa)
- PowerOfAttorneyService.CopyNextToDocument(poa, targetPath);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!(await File.ReadAllBytesAsync(targetPath, cancellationToken)).AsSpan().SequenceEqual(data))
+ throw new IOException("Документ изменился во время подписания. Подпись не сохранена; повторите операцию.");
+ if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath);
+ await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken);
// Режим «копия отдельно»: штампованная копия БЕЗ подписи, со всеми
// подписантами итогового .sig — пересоздаётся после каждого подписания.
@@ -198,7 +211,7 @@ public async Task SignFileAsync(
: new List { certificate };
stampedCopyPath = PdfStamper.CreateStampedCopy(
filePath, stampCerts, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ poaPackage?.Info.Number);
foreach (var c in signerCerts)
c.Dispose();
}
@@ -355,21 +368,11 @@ private static byte[][] BuildChainBlobs(X509Certificate2 certificate)
///
/// Проверяет откреплённую подпись для файла (только криптографическую
- /// корректность, без проверки доверия цепочки). ГОСТ-подписи вне Windows
- /// проверить нельзя — SignedCms их не разбирает.
+ /// корректность, без проверки доверия цепочки). ГОСТ поддерживается
+ /// управляемой библиотекой на любой платформе без криптопровайдера.
///
public bool VerifyDetached(byte[] data, byte[] signature)
{
- var signedCms = new SignedCms(new ContentInfo(data), detached: true);
- signedCms.Decode(signature);
- try
- {
- signedCms.CheckSignature(verifySignatureOnly: true);
- return true;
- }
- catch (System.Security.Cryptography.CryptographicException)
- {
- return false;
- }
+ return SignatureVerifier.Verify(signature, data, VerificationOptions.CryptographyOnly).CryptographicallyValid;
}
}
diff --git a/src/SignService/Services/NativeSign.cs b/src/SignService/Services/NativeSign.cs
index 299904b..76676bf 100644
--- a/src/SignService/Services/NativeSign.cs
+++ b/src/SignService/Services/NativeSign.cs
@@ -202,3 +202,4 @@ private static Exception NewSignError()
return new InvalidOperationException($"CryptSignMessage: {inner.Message} (0x{error:X8}).{hint}", inner);
}
}
+
diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs
index 983326f..4251b5d 100644
--- a/src/SignService/Services/PdfStamper.cs
+++ b/src/SignService/Services/PdfStamper.cs
@@ -213,3 +213,4 @@ private sealed class EmbeddedFontResolver : IFontResolver
new(bold ? "stamp#b" : "stamp#r");
}
}
+
diff --git a/src/SignService/Services/PowerOfAttorneyService.cs b/src/SignService/Services/PowerOfAttorneyService.cs
index 211bccf..cc60130 100644
--- a/src/SignService/Services/PowerOfAttorneyService.cs
+++ b/src/SignService/Services/PowerOfAttorneyService.cs
@@ -5,6 +5,8 @@
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Xml.Linq;
+using System.Xml;
+using System.Collections.Generic;
namespace SignService.Services;
@@ -33,7 +35,13 @@ public sealed record PoaInfo(
string PrincipalInn,
string RepresentativeName,
string RepresentativeInn,
- string RepresentativeSnils);
+ string RepresentativeSnils)
+ {
+ public IReadOnlyList Representatives { get; init; } = Array.Empty();
+ }
+
+ public sealed record Representative(string Name, string Inn, string Snils);
+ internal sealed record PoaPackage(PoaInfo Info, byte[] Xml, byte[] Signature, CheckResult Check);
public enum CheckState
{
@@ -48,7 +56,15 @@ public sealed record CheckResult(CheckState State, string Message);
/// Разбирает XML МЧД (EMCHD_1). Пространство имён допускается любое.
public static PoaInfo Parse(string xmlPath, string sigPath)
{
- var doc = XDocument.Load(xmlPath);
+ return ParseBytes(File.ReadAllBytes(xmlPath), xmlPath, sigPath);
+ }
+
+ private static PoaInfo ParseBytes(byte[] xml, string xmlPath, string sigPath)
+ {
+ using var input = new MemoryStream(xml, writable: false);
+ using var reader = XmlReader.Create(input, new XmlReaderSettings
+ { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 8 * 1024 * 1024 });
+ var doc = XDocument.Load(reader);
var root = doc.Root ?? throw new InvalidOperationException("Пустой XML-файл доверенности.");
if (root.Name.LocalName != "Доверенность")
throw new InvalidOperationException(
@@ -71,13 +87,12 @@ public static PoaInfo Parse(string xmlPath, string sigPath)
var org = Find(root, "СвРосОрг");
// Представитель — в блоке СвУпПред; у руководителя (ЛицоБезДов) похожие поля,
// поэтому ищем строго внутри СвУпПред.
- var pred = Find(root, "СвУпПред") is { } up ? Find(up, "СведФизЛ") : null;
- var fio = pred is not null ? Find(pred, "ФИО") : null;
-
- var repName = string.Join(" ", new[]
- {
- Attr(fio, "Фамилия"), Attr(fio, "Имя"), Attr(fio, "Отчество"),
- }.Where(s => s.Length > 0));
+ var representatives = root.Descendants().Where(e => e.Name.LocalName == "СвУпПред")
+ .SelectMany(e => e.Descendants().Where(p => p.Name.LocalName == "СведФизЛ"))
+ .Select(p => new Representative(string.Join(" ", new[] { Attr(Find(p, "ФИО"), "Фамилия"),
+ Attr(Find(p, "ФИО"), "Имя"), Attr(Find(p, "ФИО"), "Отчество") }.Where(s => s.Length > 0)),
+ Attr(p, "ИННФЛ"), Attr(p, "СНИЛС"))).Distinct().ToArray();
+ var representative = representatives.FirstOrDefault() ?? new Representative("", "", "");
return new PoaInfo(
xmlPath,
@@ -87,68 +102,96 @@ public static PoaInfo Parse(string xmlPath, string sigPath)
Date(Attr(svDov, "СрокДейст")),
Attr(org, "НаимОрг"),
Attr(org, "ИННЮЛ"),
- repName,
- Attr(pred, "ИННФЛ"),
- Attr(pred, "СНИЛС"));
+ representative.Name, representative.Inn, representative.Snils) { Representatives = representatives };
}
///
- /// Проверяет доверенность: подпись руководителя соответствует XML (по messageDigest,
- /// для ГОСТ — Стрибогом), срок действия не истёк, представитель в МЧД совпадает
+ /// Проверяет доверенность: подпись руководителя криптографически соответствует XML,
+ /// срок действия не истёк, представитель в МЧД совпадает
/// с выбранным сертификатом (по ИНН/СНИЛС из subject).
///
- public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertificate)
+ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertificate, VerificationOptions? options = null)
{
- // 1. Подпись руководителя соответствует файлу МЧД
try
{
- var xml = File.ReadAllBytes(poa.XmlPath);
- var sig = File.ReadAllBytes(poa.SigPath);
- switch (CmsMerger.CheckAgainstDocument(sig, xml))
- {
- case CmsMerger.DocMatch.Mismatch:
- return new CheckResult(CheckState.Error,
- "Подпись руководителя НЕ соответствует файлу МЧД (подписан другой файл или другая версия).");
- case CmsMerger.DocMatch.Unknown:
- return new CheckResult(CheckState.Warning,
- "Не удалось проверить соответствие подписи руководителя файлу МЧД.");
- }
+ return Prepare(poa, signerCertificate, options).Check;
}
catch (Exception e)
{
return new CheckResult(CheckState.Error, "Не удалось разобрать подпись руководителя: " + e.Message);
}
- // 2. Срок действия
- if (poa.ValidTo is { } validTo && validTo.Date < DateTime.Today)
- return new CheckResult(CheckState.Error,
- $"Срок действия МЧД истёк {validTo:dd.MM.yyyy}.");
+ }
- // 3. Представитель соответствует сертификату подписанта
- if (signerCertificate is not null)
- {
- var certInn = Digits(SubjectValue(signerCertificate, InnFlOid))
- is { Length: > 0 } innFl ? innFl : Digits(SubjectValue(signerCertificate, InnLegacyOid));
- var certSnils = Digits(SubjectValue(signerCertificate, SnilsOid));
- var poaInn = Digits(poa.RepresentativeInn);
- var poaSnils = Digits(poa.RepresentativeSnils);
+ internal static PoaPackage Prepare(PoaInfo selected, X509Certificate2? signerCertificate,
+ VerificationOptions? options = null)
+ {
+ // Parse, verify and later copy exactly these bytes, never cached fields or a second read.
+ var xml = File.ReadAllBytes(selected.XmlPath);
+ var sig = File.ReadAllBytes(selected.SigPath);
+ var poa = ParseBytes(xml, selected.XmlPath, selected.SigPath);
+ var result = Check();
+ return new PoaPackage(poa, xml, sig, result);
- var innOk = certInn.Length > 0 && poaInn.Length > 0 && certInn == poaInn;
- var snilsOk = certSnils.Length > 0 && poaSnils.Length > 0 && certSnils == poaSnils;
+ CheckResult Check()
+ {
+ var verification = SignatureVerifier.Verify(sig, xml, options);
+ if (!verification.CryptographicallyValid)
+ return new CheckResult(CheckState.Error, "Подпись руководителя не прошла полную криптографическую проверку МЧД. "
+ + verification.Container.Message + " " + string.Join("; ", verification.Signers.Select(s => s.Signature.Message + " " + s.Document.Message)));
+ if (string.IsNullOrWhiteSpace(poa.Number) || poa.IssueDate is null || poa.ValidTo is null
+ || poa.ValidTo < poa.IssueDate)
+ return new CheckResult(CheckState.Error, "В МЧД отсутствуют или некорректны номер и даты действия.");
+ if (poa.IssueDate.Value.Date > DateTime.Today)
+ return new CheckResult(CheckState.Error, "МЧД ещё не вступила в силу.");
+ if (poa.ValidTo is { } validTo && validTo.Date < DateTime.Today)
+ return new CheckResult(CheckState.Error,
+ $"Срок действия МЧД истёк {validTo:dd.MM.yyyy}.");
- if (!innOk && !snilsOk)
+ var warnings = new List();
+ // Представитель соответствует сертификату подписанта.
+ if (signerCertificate is not null)
{
- if (certInn.Length == 0 && certSnils.Length == 0)
- return new CheckResult(CheckState.Warning,
- "В сертификате нет ИНН/СНИЛС — соответствие представителя МЧД не проверено.");
- return new CheckResult(CheckState.Error,
- $"Представитель в МЧД ({poa.RepresentativeName}) не совпадает с владельцем сертификата по ИНН/СНИЛС.");
+ var certInn = Digits(SubjectValue(signerCertificate, InnFlOid))
+ is { Length: > 0 } innFl ? innFl : Digits(SubjectValue(signerCertificate, InnLegacyOid));
+ var certSnils = Digits(SubjectValue(signerCertificate, SnilsOid));
+ var match = poa.Representatives.FirstOrDefault(r =>
+ {
+ var inn = Digits(r.Inn); var snils = Digits(r.Snils);
+ return (certInn.Length == 0 || inn.Length == 0 || certInn == inn)
+ && (certSnils.Length == 0 || snils.Length == 0 || certSnils == snils)
+ && (certInn.Length > 0 && certInn == inn || certSnils.Length > 0 && certSnils == snils);
+ });
+ if (match is null)
+ {
+ if (certInn.Length == 0 && certSnils.Length == 0)
+ warnings.Add("В сертификате нет ИНН/СНИЛС — соответствие представителя МЧД не проверено.");
+ else
+ return new CheckResult(CheckState.Error,
+ $"Представитель в МЧД ({poa.RepresentativeName}) не совпадает с владельцем сертификата по ИНН/СНИЛС.");
+ }
+ else
+ poa = poa with { RepresentativeName = match.Name, RepresentativeInn = match.Inn, RepresentativeSnils = match.Snils };
+ }
+ foreach (var signer in verification.Signers)
+ {
+ if (signer.CertificateTrust.State == VerificationState.Invalid || signer.Revocation.State == VerificationState.Invalid)
+ return new CheckResult(CheckState.Error, signer.CertificateTrust.Message + " " + signer.Revocation.Message);
+ if (signer.CertificateTrust.State != VerificationState.Valid || signer.Revocation.State != VerificationState.Valid)
+ warnings.Add("Доверие или отзыв сертификата руководителя не подтверждены.");
+ var principalInn = Digits(poa.PrincipalInn);
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(signer.Certificate!);
+ var innValues = cert.SubjectDN.GetValueList(new Org.BouncyCastle.Asn1.DerObjectIdentifier(InnLegacyOid));
+ if (innValues.Count > 0 && principalInn.Length > 0 && innValues.Any(v => Digits(v) != principalInn))
+ return new CheckResult(CheckState.Error, "ИНН организации в сертификате руководителя не совпадает с доверителем МЧД.");
+ if (innValues.Count == 0 || principalInn.Length == 0)
+ warnings.Add("Связь сертификата руководителя с организацией-доверителем не подтверждена по ИНН.");
}
+ if (signerCertificate is null) warnings.Add("Сертификат представителя не выбран.");
+ const string passed = "Подпись МЧД криптографически корректна, срок действия проверен. ";
+ return new CheckResult(warnings.Count > 0 ? CheckState.Warning : CheckState.Ok,
+ passed + string.Join(" ", warnings.Distinct()) + " Полномочия и отзыв самой МЧД в реестре проверяются отдельно.");
}
-
- return new CheckResult(CheckState.Ok, "Доверенность проверена: подпись руководителя "
- + "соответствует МЧД, срок действия не истёк"
- + (signerCertificate is null ? "." : ", представитель совпадает с сертификатом."));
}
///
@@ -157,13 +200,42 @@ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertific
///
public static void CopyNextToDocument(PoaInfo poa, string documentPath)
{
- var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath)) ?? ".";
- foreach (var source in new[] { poa.XmlPath, poa.SigPath })
+ var package = Prepare(poa, null);
+ if (package.Check.State == CheckState.Error) throw new InvalidOperationException(package.Check.Message);
+ CopyPackage(package, documentPath);
+ }
+
+ internal static void CheckCopyTargets(PoaPackage package, string documentPath)
+ {
+ foreach (var (target, bytes) in CopyTargets(package, documentPath))
+ if (File.Exists(target) && !File.ReadAllBytes(target).AsSpan().SequenceEqual(bytes))
+ throw new IOException($"Рядом с документом уже есть другой файл МЧД «{Path.GetFileName(target)}». Выберите другой каталог.");
+ }
+
+ internal static void CopyPackage(PoaPackage package, string documentPath)
+ {
+ CheckCopyTargets(package, documentPath);
+ var created = new List();
+ try
{
- var target = Path.Combine(directory, Path.GetFileName(source));
- if (!File.Exists(target))
- File.Copy(source, target);
+ foreach (var (target, bytes) in CopyTargets(package, documentPath))
+ if (!File.Exists(target)) { AtomicFile.Write(target, bytes); created.Add(target); }
+ CheckCopyTargets(package, documentPath);
}
+ catch { foreach (var path in created) File.Delete(path); throw; }
+ }
+
+ private static IEnumerable<(string Path, byte[] Bytes)> CopyTargets(PoaPackage package, string documentPath)
+ {
+ var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath))!;
+ var xmlTarget = Path.Combine(directory, Path.GetFileName(package.Info.XmlPath));
+ var sigTarget = Path.Combine(directory, Path.GetFileName(package.Info.SigPath));
+ if (string.Equals(xmlTarget, sigTarget, StringComparison.OrdinalIgnoreCase)
+ || new[] { xmlTarget, sigTarget }.Any(p => string.Equals(p, Path.GetFullPath(documentPath), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(p, Path.GetFullPath(documentPath + ".sig"), StringComparison.OrdinalIgnoreCase)))
+ throw new IOException("Имена файлов МЧД совпадают с документом или его подписью.");
+ yield return (xmlTarget, package.Xml);
+ yield return (sigTarget, package.Signature);
}
/// Значение RDN субъекта сертификата по OID (СНИЛС/ИНН и т.п.).
diff --git a/src/SignService/Services/RevocationChecker.cs b/src/SignService/Services/RevocationChecker.cs
new file mode 100644
index 0000000..7e9619c
--- /dev/null
+++ b/src/SignService/Services/RevocationChecker.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Ocsp;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Ocsp;
+using Org.BouncyCastle.X509;
+
+namespace SignService.Services;
+
+/// Conservative CRL/OCSP validation. Missing, stale or unverifiable evidence is never "good".
+internal static class RevocationChecker
+{
+ private static readonly HttpClient Http = new(new HttpClientHandler { AllowAutoRedirect = false })
+ { Timeout = TimeSpan.FromSeconds(8) };
+ private const int MaxResponseBytes = 4 * 1024 * 1024;
+
+ public static VerificationCheck Check(IReadOnlyList chain, IReadOnlyList embeddedCrls,
+ VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var crls = embeddedCrls.ToList();
+ foreach (var bytes in options.Crls)
+ try { crls.Add(new X509CrlParser().ReadCrl(bytes)); } catch (Exception) { }
+ var unknown = new List();
+ for (var i = 0; i + 1 < chain.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var cert = chain[i];
+ var issuer = chain[i + 1];
+ var evidence = new List();
+ foreach (var crl in crls) evidence.Add(CheckCrl(crl, cert, issuer, options.ValidationTime));
+ foreach (var response in options.OcspResponses)
+ evidence.Add(CheckOcsp(response, cert, issuer, options.ValidationTime));
+ if (options.AllowNetwork && !evidence.Any(e => e.State is VerificationState.Valid or VerificationState.Invalid))
+ {
+ foreach (var uri in OcspUrls(cert).Take(2))
+ {
+ var generator = new OcspReqGenerator();
+ generator.AddRequest(new CertificateID(new AlgorithmIdentifier(new DerObjectIdentifier("1.3.14.3.2.26")), issuer, cert.SerialNumber));
+ var response = Download(uri, generator.Generate().GetEncoded(), cancellationToken);
+ if (response is not null) evidence.Add(CheckOcsp(response, cert, issuer, options.ValidationTime));
+ }
+ foreach (var uri in CrlUrls(cert).Take(2))
+ {
+ var response = Download(uri, null, cancellationToken);
+ if (response is null) continue;
+ try { evidence.Add(CheckCrl(new X509CrlParser().ReadCrl(response), cert, issuer, options.ValidationTime)); }
+ catch (Exception) { }
+ }
+ }
+ // Any authenticated evidence of revocation takes precedence over a "good" response.
+ var revoked = evidence.FirstOrDefault(e => e.State == VerificationState.Invalid);
+ if (revoked is not null) return revoked;
+ if (!evidence.Any(e => e.State == VerificationState.Valid)) unknown.Add(cert.SubjectDN.ToString());
+ }
+ return unknown.Count == 0 ? VerificationCheck.Valid(chain.Count <= 1
+ ? "Цепочка состоит из точки доверия; для корневого сертификата отзыв не оценивается."
+ : "По свежим подписанным CRL/OCSP сертификаты цепочки не отозваны (корень — точка доверия).")
+ : VerificationCheck.Unknown("Нет свежих достоверных CRL/OCSP для: " + string.Join("; ", unknown)
+ + (options.AllowNetwork ? ". Запросы в сеть не дали подтверждения." : ". Сетевые запросы отключены."));
+ }
+
+ private static VerificationCheck CheckCrl(X509Crl crl, X509Certificate cert, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ if (!crl.IssuerDN.Equivalent(issuer.SubjectDN)) return Unknown();
+ var usage = issuer.GetKeyUsage();
+ if (usage is not null && !usage[6]) return Unknown();
+ crl.Verify(issuer.GetPublicKey());
+ // Delta, indirect, partitioned or critical CRLs need additional processing. Do not infer "good" from them.
+ if (crl.GetExtensionValue(X509Extensions.DeltaCrlIndicator) is not null
+ || crl.GetExtensionValue(X509Extensions.IssuingDistributionPoint) is not null
+ || crl.GetCriticalExtensionOids() is { Count: > 0 }) return Unknown();
+ if (!Fresh(crl.ThisUpdate, crl.NextUpdate, at)) return Unknown();
+ var entry = crl.GetRevokedCertificate(cert.SerialNumber);
+ if (entry is not null)
+ {
+ if (entry.GetCriticalExtensionOids() is { Count: > 0 }) return Unknown();
+ if (entry.RevocationDate <= at.UtcDateTime)
+ return VerificationCheck.Invalid("Сертификат отозван по подписанному CRL: " + cert.SubjectDN);
+ }
+ return VerificationCheck.Valid("Свежий CRL издателя: сертификат не отозван.");
+ }
+ catch (Exception) { return Unknown(); }
+ }
+
+ private static VerificationCheck CheckOcsp(byte[] encoded, X509Certificate cert, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ var response = new OcspResp(encoded);
+ if (response.Status != 0 || response.GetResponseObject() is not BasicOcspResp basic
+ || basic.GetCriticalExtensionOids() is { Count: > 0 }
+ || basic.ProducedAt > at.UtcDateTime.AddMinutes(5)) return Unknown();
+ var responders = basic.GetCerts().Append(issuer);
+ var authenticated = responders.Any(responder => AuthorizedResponder(basic, responder, issuer, at));
+ if (!authenticated) return Unknown();
+ foreach (var single in basic.Responses)
+ {
+ var id = single.GetCertID();
+ if (!id.SerialNumber.Equals(cert.SerialNumber) || !id.MatchesIssuer(issuer)
+ || single.GetCriticalExtensionOids() is { Count: > 0 }
+ || basic.ProducedAt < single.ThisUpdate.AddMinutes(-5)
+ || !Fresh(single.ThisUpdate, single.NextUpdate, at)) continue;
+ var status = single.GetCertStatus();
+ if (status is RevokedStatus revoked && revoked.RevocationTime <= at.UtcDateTime)
+ return VerificationCheck.Invalid("Сертификат отозван по подписанному OCSP: " + cert.SubjectDN);
+ if (status is null) return VerificationCheck.Valid("Свежий OCSP: статус good.");
+ }
+ return Unknown();
+ }
+ catch (Exception) { return Unknown(); }
+ }
+
+ private static bool AuthorizedResponder(BasicOcspResp response, X509Certificate responder, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ if (!response.ResponderId.Equals(new RespID(responder.SubjectDN))
+ && !response.ResponderId.Equals(new RespID(responder.GetPublicKey()))) return false;
+ if (!responder.Equals(issuer))
+ {
+ if (!responder.IssuerDN.Equivalent(issuer.SubjectDN)) return false;
+ responder.Verify(issuer.GetPublicKey());
+ responder.CheckValidity(at.UtcDateTime);
+ if (responder.GetExtendedKeyUsage()?.Contains(KeyPurposeID.id_kp_OCSPSigning) != true) return false;
+ var usage = responder.GetKeyUsage();
+ if (usage is not null && !usage[0]) return false;
+ // Unrecognized critical extensions cannot be ignored on a delegated responder.
+ var allowed = new[] { X509Extensions.KeyUsage.Id, X509Extensions.ExtendedKeyUsage.Id,
+ X509Extensions.BasicConstraints.Id, OcspObjectIdentifiers.PkixOcspNocheck.Id };
+ if (responder.GetCriticalExtensionOids()?.Any(oid => !allowed.Contains(oid)) == true) return false;
+ }
+ return response.Verify(responder.GetPublicKey());
+ }
+ catch (Exception) { return false; }
+ }
+
+ private static bool Fresh(DateTime thisUpdate, DateTime? nextUpdate, DateTimeOffset at) =>
+ thisUpdate <= at.UtcDateTime.AddMinutes(5) && (nextUpdate is { } next
+ ? next >= thisUpdate && next >= at.UtcDateTime
+ : thisUpdate >= at.UtcDateTime.AddHours(-24));
+
+ private static VerificationCheck Unknown() => VerificationCheck.Unknown("Доказательство отзыва неприменимо, устарело или не прошло проверку.");
+
+ private static IEnumerable OcspUrls(X509Certificate cert)
+ {
+ var ext = cert.GetExtensionValue(X509Extensions.AuthorityInfoAccess);
+ if (ext is null) return Array.Empty();
+ try
+ {
+ return AuthorityInformationAccess.GetInstance(Asn1Object.FromByteArray(ext.GetOctets())).GetAccessDescriptions()
+ .Where(a => a.AccessMethod.Equals(AccessDescription.IdADOcsp)).Select(a => Url(a.AccessLocation)).OfType().ToArray();
+ }
+ catch (Exception) { return Array.Empty(); }
+ }
+
+ private static IEnumerable CrlUrls(X509Certificate cert)
+ {
+ var ext = cert.GetExtensionValue(X509Extensions.CrlDistributionPoints);
+ if (ext is null) return Array.Empty();
+ try
+ {
+ return CrlDistPoint.GetInstance(Asn1Object.FromByteArray(ext.GetOctets())).GetDistributionPoints()
+ .Where(d => d.DistributionPointName?.Type == DistributionPointName.FullName)
+ .SelectMany(d => GeneralNames.GetInstance(d.DistributionPointName.Name).GetNames())
+ .Select(Url).OfType().ToArray();
+ }
+ catch (Exception) { return Array.Empty(); }
+ }
+
+ private static Uri? Url(GeneralName name) => name.TagNo == GeneralName.UniformResourceIdentifier
+ && Uri.TryCreate(DerIA5String.GetInstance(name.Name).GetString(), UriKind.Absolute, out var uri)
+ && (uri.Scheme == "http" || uri.Scheme == "https") ? uri : null;
+
+ private static byte[]? Download(Uri uri, byte[]? ocspRequest, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var request = new HttpRequestMessage(ocspRequest is null ? HttpMethod.Get : HttpMethod.Post, uri);
+ if (ocspRequest is not null)
+ {
+ request.Content = new ByteArrayContent(ocspRequest);
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/ocsp-request");
+ }
+ using var response = Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();
+ if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxResponseBytes) return null;
+ using var stream = response.Content.ReadAsStreamAsync(cancellationToken).GetAwaiter().GetResult();
+ using var body = new MemoryStream();
+ var buffer = new byte[8192];
+ int count;
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(TimeSpan.FromSeconds(8));
+ while ((count = stream.ReadAsync(buffer, deadline.Token).AsTask().GetAwaiter().GetResult()) != 0)
+ {
+ if (body.Length + count > MaxResponseBytes) return null;
+ body.Write(buffer, 0, count);
+ }
+ return body.ToArray();
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception) { return null; }
+ }
+}
diff --git a/src/SignService/Services/SavedCertificateInfo.cs b/src/SignService/Services/SavedCertificateInfo.cs
index 7e5c3bf..bf55f79 100644
--- a/src/SignService/Services/SavedCertificateInfo.cs
+++ b/src/SignService/Services/SavedCertificateInfo.cs
@@ -28,3 +28,4 @@ public class SavedCertificateInfo
///
public string CertificateBase64 { get; set; } = "";
}
+
diff --git a/src/SignService/Services/SignatureVerification.cs b/src/SignService/Services/SignatureVerification.cs
new file mode 100644
index 0000000..78363df
--- /dev/null
+++ b/src/SignService/Services/SignatureVerification.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace SignService.Services;
+
+public enum VerificationState { Valid, Invalid, Unknown, NotChecked, NotPresent }
+
+public sealed record VerificationCheck(VerificationState State, string Message)
+{
+ public static VerificationCheck Valid(string message) => new(VerificationState.Valid, message);
+ public static VerificationCheck Invalid(string message) => new(VerificationState.Invalid, message);
+ public static VerificationCheck Unknown(string message) => new(VerificationState.Unknown, message);
+ public static VerificationCheck NotChecked(string message) => new(VerificationState.NotChecked, message);
+}
+
+public sealed record VerificationOptions
+{
+ public bool CheckCertificateTrust { get; init; } = true;
+ public bool CheckRevocation { get; init; } = true;
+ public bool AllowNetwork { get; init; }
+ public bool UseSystemTrustStore { get; init; } = true;
+ public DateTimeOffset ValidationTime { get; init; } = DateTimeOffset.UtcNow;
+ public IReadOnlyList TrustedRoots { get; init; } = Array.Empty();
+ public IReadOnlyList ExtraCertificates { get; init; } = Array.Empty();
+ public IReadOnlyList Crls { get; init; } = Array.Empty();
+ public IReadOnlyList OcspResponses { get; init; } = Array.Empty();
+
+ internal static VerificationOptions CryptographyOnly => new()
+ {
+ CheckCertificateTrust = false, CheckRevocation = false, UseSystemTrustStore = false,
+ };
+}
+
+public sealed record SignerVerification(
+ int Number, string Name, string Issuer, string SerialNumber, string Thumbprint,
+ string DigestAlgorithm, DateTime NotBefore, DateTime NotAfter, DateTimeOffset? SigningTime,
+ VerificationCheck Document, VerificationCheck Signature, VerificationCheck CertificateBinding,
+ VerificationCheck CertificateTrust, VerificationCheck Revocation, VerificationCheck Timestamp,
+ DateTimeOffset? TimestampTime, byte[]? Certificate)
+{
+ public bool CryptographicallyValid => Signature.State == VerificationState.Valid
+ && Document.State == VerificationState.Valid
+ && CertificateBinding.State is VerificationState.Valid or VerificationState.NotPresent;
+}
+
+public sealed record SignatureVerificationResult(
+ bool Attached, VerificationCheck Container, IReadOnlyList Signers)
+{
+ public bool CryptographicallyValid => Container.State == VerificationState.Valid
+ && Signers.Count > 0 && Signers.All(s => s.CryptographicallyValid);
+
+ public string ToReport(string signatureName, string? documentName)
+ {
+ var text = new StringBuilder();
+ text.AppendLine("ПРОВЕРКА ЭЛЕКТРОННОЙ ПОДПИСИ");
+ text.AppendLine("Подпись: " + signatureName);
+ text.AppendLine("Документ: " + (documentName ?? (Attached ? "вложен в контейнер" : "не выбран")));
+ text.AppendLine("Контейнер: " + Container.Message);
+ text.AppendLine($"Подписантов: {Signers.Count}");
+ foreach (var signer in Signers)
+ {
+ text.AppendLine();
+ text.AppendLine($"{signer.Number}. {signer.Name}");
+ text.AppendLine(" Издатель: " + signer.Issuer);
+ text.AppendLine(" Серийный номер: " + signer.SerialNumber);
+ text.AppendLine(" Отпечаток SHA-256: " + signer.Thumbprint);
+ text.AppendLine($" Сертификат: {signer.NotBefore:dd.MM.yyyy} — {signer.NotAfter:dd.MM.yyyy}");
+ text.AppendLine(" Алгоритм хеша: " + signer.DigestAlgorithm);
+ if (signer.SigningTime is { } time)
+ text.AppendLine($" Указанное подписантом время: {time.ToLocalTime():dd.MM.yyyy HH:mm:ss} (без подтверждения TSA)");
+ Line("Документ", signer.Document);
+ Line("Подпись", signer.Signature);
+ Line("Привязка сертификата", signer.CertificateBinding);
+ Line("Цепочка доверия", signer.CertificateTrust);
+ Line("Отзыв сертификатов", signer.Revocation);
+ Line("Метка времени", signer.Timestamp);
+ if (signer.TimestampTime is { } timestamp)
+ text.AppendLine($" Время TSA: {timestamp.ToLocalTime():dd.MM.yyyy HH:mm:ss}");
+ }
+ text.AppendLine();
+ text.AppendLine(CryptographicallyValid
+ ? "Криптографическая проверка: все подписи корректны и соответствуют документу."
+ : "Криптографическая проверка: есть ошибки или непроверенные подписи.");
+ text.AppendLine("Доверие, отзыв и метки времени указаны отдельно. «Не проверено» не означает «действительно».");
+ return text.ToString();
+
+ void Line(string label, VerificationCheck check) => text.AppendLine($" {label}: [{Label(check.State)}] {check.Message}");
+ }
+
+ public static string Label(VerificationState state) => state switch
+ {
+ VerificationState.Valid => "OK", VerificationState.Invalid => "ОШИБКА",
+ VerificationState.Unknown => "НЕ ОПРЕДЕЛЕНО", VerificationState.NotPresent => "ОТСУТСТВУЕТ",
+ _ => "НЕ ПРОВЕРЕНО",
+ };
+}
diff --git a/src/SignService/Services/SignatureVerifier.cs b/src/SignService/Services/SignatureVerifier.cs
new file mode 100644
index 0000000..2575528
--- /dev/null
+++ b/src/SignService/Services/SignatureVerifier.cs
@@ -0,0 +1,249 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Cms;
+using Org.BouncyCastle.Asn1.Ess;
+using Org.BouncyCastle.Asn1.Pkcs;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Cms;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Tsp;
+using Org.BouncyCastle.X509;
+using CmsAttribute = Org.BouncyCastle.Asn1.Cms.Attribute;
+
+namespace SignService.Services;
+
+///
+/// Проверка каждого подписанта и отдельные результаты для документа, подписи,
+/// сертификата, доверия, отзыва и TSA. Все алгоритмы, включая ГОСТ, выполняются
+/// управляемой библиотекой: криптопровайдер и закрытые ключи для проверки не нужны.
+///
+public static class SignatureVerifier
+{
+ public static Task VerifyAsync(byte[] signature, byte[]? document = null,
+ VerificationOptions? options = null, CancellationToken cancellationToken = default) =>
+ Task.Run(() => Verify(signature, document, options, cancellationToken), cancellationToken);
+
+ public static SignatureVerificationResult Verify(byte[] signature, byte[]? document = null,
+ VerificationOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ options ??= new VerificationOptions();
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var normalized = CmsMerger.Normalize(signature);
+ var original = new CmsSignedData(normalized);
+ var attached = original.SignedContent is not null;
+ var embedded = attached ? CmsMerger.ExtractContent(normalized) : null;
+ var content = document ?? embedded;
+ var cms = content is null ? original : new CmsSignedData(
+ new CmsProcessableByteArray(original.SignedContentType, content), normalized);
+ var certificates = cms.GetCertificates().EnumerateMatches(null).ToList();
+ var crls = cms.GetCrls().EnumerateMatches(null).ToList();
+ var results = new List();
+ foreach (var signer in cms.GetSignerInfos().GetSigners())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var matches = cms.GetCertificates().EnumerateMatches(signer.SignerID).ToList();
+ results.Add(VerifySigner(signer, matches, certificates, crls, content,
+ embedded is not null && document is not null && !embedded.AsSpan().SequenceEqual(document),
+ results.Count + 1, options, cancellationToken));
+ }
+ return new SignatureVerificationResult(attached,
+ results.Count == 0 ? VerificationCheck.Invalid("В контейнере нет подписантов.")
+ : VerificationCheck.Valid(attached ? "CMS/PKCS#7, прикреплённая подпись." : "CMS/PKCS#7, откреплённая подпись."),
+ results);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e)
+ {
+ return new SignatureVerificationResult(false,
+ VerificationCheck.Invalid("Файл подписи повреждён или не является CMS/PKCS#7: " + e.Message),
+ Array.Empty());
+ }
+ }
+
+ private static SignerVerification VerifySigner(SignerInformation signer, List matches,
+ List certificates, List crls, byte[]? document, bool attachedMismatch,
+ int number, VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var cert = matches.Count == 1 ? matches[0] : null;
+ var documentCheck = document is null
+ ? VerificationCheck.NotChecked("Для откреплённой подписи выберите исходный документ.")
+ : CheckDocument(signer, document);
+ if (attachedMismatch)
+ documentCheck = VerificationCheck.Invalid("Вложенный документ отличается от выбранного файла.");
+ var binding = cert is null ? VerificationCheck.Unknown("Сертификат подписанта отсутствует или неоднозначен.")
+ : CheckCertificateBinding(signer, cert);
+ var signatureCheck = VerificationCheck.Unknown("Сертификат подписанта отсутствует или неоднозначен.");
+ if (cert is not null && document is not null && documentCheck.State == VerificationState.Unknown)
+ signatureCheck = VerificationCheck.Unknown("Проверка подписи невозможна: " + documentCheck.Message);
+ else if (cert is not null && document is not null)
+ {
+ try
+ {
+ signatureCheck = signer.Verify(cert.GetPublicKey())
+ ? VerificationCheck.Valid("Значение подписи проверено открытым ключом сертификата.")
+ : VerificationCheck.Invalid("Значение подписи неверно: подпись повреждена или подделана.");
+ }
+ catch (Exception e) when (UnsupportedAlgorithm(e)) { signatureCheck = VerificationCheck.Unknown("Алгоритм не поддерживается: " + e.Message); }
+ catch (Exception e) { signatureCheck = VerificationCheck.Invalid("Подпись не прошла проверку: " + e.Message); }
+ }
+ else if (document is null)
+ signatureCheck = VerificationCheck.NotChecked("Нет исходного документа для полной проверки подписи.");
+
+ var timestamp = VerifyTimestamp(signer, options, cancellationToken);
+ // signing-time заявлен самим подписантом. Исторический момент допустим только
+ // при криптографически корректной метке от доверенной TSA.
+ var at = timestamp.Trusted && timestamp.Time is { } stamped ? stamped : options.ValidationTime;
+ var certificate = cert is null ? new CertificateValidation(
+ VerificationCheck.Unknown("Нет сертификата подписанта."), VerificationCheck.Unknown("Нет сертификата подписанта."))
+ : CertificateValidator.Validate(cert, certificates, crls, options with { ValidationTime = at }, cancellationToken);
+ return new SignerVerification(number, cert?.SubjectDN.ToString() ?? "Неизвестный подписант",
+ cert?.IssuerDN.ToString() ?? "", cert?.SerialNumber.ToString(16).ToUpperInvariant() ?? "",
+ cert is null ? "" : Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.GetEncoded())),
+ AlgorithmName(signer.DigestAlgorithmID.Algorithm.Id), cert?.NotBefore ?? default, cert?.NotAfter ?? default,
+ SigningTime(signer), documentCheck, signatureCheck, binding, certificate.Trust,
+ certificate.Revocation, timestamp.Check, timestamp.Time, cert?.GetEncoded());
+ }
+
+ private static VerificationCheck CheckDocument(SignerInformation signer, byte[] document)
+ {
+ try
+ {
+ var attr = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtMessageDigest];
+ if (attr is null)
+ return VerificationCheck.Valid("Документ проверяется непосредственно значением подписи (без signedAttrs).");
+ if (attr.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Некорректный атрибут messageDigest.");
+ var digest = Asn1OctetString.GetInstance(attr.AttrValues[0]).GetOctets();
+ return DigestUtilities.CalculateDigest(signer.DigestAlgorithmID.Algorithm.Id, document).AsSpan().SequenceEqual(digest)
+ ? VerificationCheck.Valid("Хеш соответствует текущему содержимому документа.")
+ : VerificationCheck.Invalid("Хеш не совпал: подпись относится к другому файлу или его прежней версии.");
+ }
+ catch (SecurityUtilityException e) { return VerificationCheck.Unknown("Алгоритм хеша не поддерживается: " + e.Message); }
+ catch (Exception e) { return VerificationCheck.Invalid("Не удалось проверить messageDigest: " + e.Message); }
+ }
+
+ internal static VerificationCheck CheckCertificateBinding(SignerInformation signer, X509Certificate certificate)
+ {
+ try
+ {
+ var oidV2 = new DerObjectIdentifier(CadesAttributes.SigningCertificateV2Oid);
+ var oidV1 = new DerObjectIdentifier("1.2.840.113549.1.9.16.2.12");
+ var v2 = signer.SignedAttributes?[oidV2];
+ var v1 = signer.SignedAttributes?[oidV1];
+ if (v1 is null && v2 is null)
+ return new VerificationCheck(VerificationState.NotPresent, "Обычная CMS-подпись: signing-certificate отсутствует.");
+ if (v2 is not null)
+ {
+ if (signer.SignedAttributes!.GetAll(oidV2).Count != 1 || v2.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Неоднозначный signing-certificate-v2.");
+ var ids = SigningCertificateV2.GetInstance(v2.AttrValues[0]).GetCerts();
+ if (ids.Length == 0 || !DigestUtilities.CalculateDigest(ids[0].HashAlgorithm.Algorithm.Id,
+ certificate.GetEncoded()).AsSpan().SequenceEqual(ids[0].GetCertHash())
+ || !IssuerMatches(ids[0].IssuerSerial, certificate))
+ return VerificationCheck.Invalid("signing-certificate-v2 не соответствует сертификату подписанта.");
+ }
+ if (v1 is not null)
+ {
+ if (signer.SignedAttributes!.GetAll(oidV1).Count != 1 || v1.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Неоднозначный signing-certificate.");
+ var ids = SigningCertificate.GetInstance(v1.AttrValues[0]).GetCerts();
+ if (ids.Length == 0 || !DigestUtilities.CalculateDigest("SHA-1", certificate.GetEncoded())
+ .AsSpan().SequenceEqual(ids[0].GetCertHash()) || !IssuerMatches(ids[0].IssuerSerial, certificate))
+ return VerificationCheck.Invalid("signing-certificate не соответствует сертификату подписанта.");
+ }
+ return VerificationCheck.Valid("Хеш и идентификатор сертификата в подписанных атрибутах совпали.");
+ }
+ catch (SecurityUtilityException e) { return VerificationCheck.Unknown("Алгоритм привязки сертификата не поддерживается: " + e.Message); }
+ catch (Exception e) { return VerificationCheck.Invalid("Некорректная привязка сертификата: " + e.Message); }
+ }
+
+ private static bool IssuerMatches(IssuerSerial? issuer, X509Certificate certificate) => issuer is null
+ || (issuer.Serial.Value.Equals(certificate.SerialNumber) && issuer.Issuer.GetNames().Any(n =>
+ n.TagNo == GeneralName.DirectoryName && X509Name.GetInstance(n.Name).Equivalent(certificate.IssuerDN)));
+
+ internal sealed record TimestampVerification(VerificationCheck Check, DateTimeOffset? Time, bool Trusted);
+
+ private static TimestampVerification VerifyTimestamp(SignerInformation signer, VerificationOptions options,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var oid = new DerObjectIdentifier(TimestampClient.TimeStampTokenOid);
+ var attr = signer.UnsignedAttributes?[oid];
+ if (attr is null)
+ return new TimestampVerification(new VerificationCheck(VerificationState.NotPresent,
+ "Метки TSA нет. signing-time не подтверждает время создания подписи."), null, false);
+ if (signer.UnsignedAttributes!.GetAll(oid).Count != 1 || attr.AttrValues.Count != 1)
+ return new TimestampVerification(VerificationCheck.Invalid("Неоднозначный атрибут метки времени."), null, false);
+ return VerifyTimestampToken(attr.AttrValues[0].GetEncoded(), signer.GetSignature(), options, cancellationToken);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e) { return new TimestampVerification(VerificationCheck.Invalid("Некорректная метка времени: " + e.Message), null, false); }
+ }
+
+ internal static TimestampVerification VerifyTimestampToken(byte[] encodedToken, byte[] signatureValue,
+ VerificationOptions options, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var token = new TimeStampToken(Org.BouncyCastle.Asn1.Cms.ContentInfo.GetInstance(Asn1Object.FromByteArray(encodedToken)));
+ var info = token.TimeStampInfo;
+ if (!DigestUtilities.CalculateDigest(info.MessageImprintAlgOid, signatureValue).AsSpan()
+ .SequenceEqual(info.GetMessageImprintDigest()))
+ return new TimestampVerification(VerificationCheck.Invalid("Метка TSA относится к другому значению подписи."), null, false);
+ var matches = token.GetCertificates().EnumerateMatches(token.SignerID).ToList();
+ if (matches.Count != 1)
+ return new TimestampVerification(VerificationCheck.Invalid("Нет однозначного сертификата TSA."), null, false);
+ token.Validate(matches[0]); // подпись, ESSCertID, EKU timeStamping и срок сертификата
+ var time = new DateTimeOffset(DateTime.SpecifyKind(info.GenTime, DateTimeKind.Utc));
+ if (time > options.ValidationTime.AddMinutes(5))
+ return new TimestampVerification(VerificationCheck.Invalid("Метка TSA указывает время в будущем."), time, false);
+ var validation = CertificateValidator.Validate(matches[0], token.GetCertificates().EnumerateMatches(null).ToList(),
+ token.GetCrls().EnumerateMatches(null).ToList(), options with { ValidationTime = time }, cancellationToken);
+ if (!options.CheckCertificateTrust)
+ return new TimestampVerification(VerificationCheck.Valid("Подпись и хеш TSA корректны; доверие TSA не проверялось."), time, false);
+ var trusted = validation.Trust.State == VerificationState.Valid
+ && (!options.CheckRevocation || validation.Revocation.State == VerificationState.Valid);
+ return new TimestampVerification(trusted
+ ? VerificationCheck.Valid("Подпись, хеш, цепочка доверия и требуемые проверки TSA пройдены.")
+ : new VerificationCheck(validation.Trust.State == VerificationState.Invalid || validation.Revocation.State == VerificationState.Invalid
+ ? VerificationState.Invalid : VerificationState.Unknown,
+ "Подпись и хеш TSA корректны. " + validation.Trust.Message + " " + validation.Revocation.Message), time, trusted);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e) { return new TimestampVerification(VerificationCheck.Invalid("Метка TSA не прошла проверку: " + e.Message), null, false); }
+ }
+
+ private static DateTimeOffset? SigningTime(SignerInformation signer)
+ {
+ try
+ {
+ var attr = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtSigningTime];
+ return attr is null ? null : new DateTimeOffset(DateTime.SpecifyKind(
+ Org.BouncyCastle.Asn1.Cms.Time.GetInstance(attr.AttrValues[0]).ToDateTime(), DateTimeKind.Utc));
+ }
+ catch { return null; }
+ }
+
+ public static string AlgorithmName(string oid) => oid switch
+ {
+ "1.2.643.7.1.1.2.2" => "ГОСТ Р 34.11-2012-256",
+ "1.2.643.7.1.1.2.3" => "ГОСТ Р 34.11-2012-512",
+ "1.2.643.2.2.9" => "ГОСТ Р 34.11-94",
+ "2.16.840.1.101.3.4.2.1" => "SHA-256", "2.16.840.1.101.3.4.2.2" => "SHA-384",
+ "2.16.840.1.101.3.4.2.3" => "SHA-512", "1.3.14.3.2.26" => "SHA-1", _ => oid,
+ };
+
+ private static bool UnsupportedAlgorithm(Exception exception)
+ {
+ for (Exception? current = exception; current is not null; current = current.InnerException)
+ if (current is SecurityUtilityException or NotSupportedException) return true;
+ return false;
+ }
+}
diff --git a/src/SignService/Services/Streebog.cs b/src/SignService/Services/Streebog.cs
index 850f68e..ed1dbae 100644
--- a/src/SignService/Services/Streebog.cs
+++ b/src/SignService/Services/Streebog.cs
@@ -722,3 +722,4 @@ private static byte[] Lps(byte[] a)
},
};
}
+
diff --git a/src/SignService/Services/TimestampClient.cs b/src/SignService/Services/TimestampClient.cs
index 1b6ff0e..58d11b9 100644
--- a/src/SignService/Services/TimestampClient.cs
+++ b/src/SignService/Services/TimestampClient.cs
@@ -4,6 +4,10 @@
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
+using System.IO;
+using Org.BouncyCastle.Tsp;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
namespace SignService.Services;
@@ -31,6 +35,8 @@ public class TimestampClient
public virtual async Task RequestTokenAsync(
byte[] signatureValue, bool gost, string tsaUrl, CancellationToken cancellationToken = default)
{
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(TimeSpan.FromSeconds(30));
byte[] hash;
string hashOid;
if (gost)
@@ -49,20 +55,20 @@ public virtual async Task RequestTokenAsync(
// старший байт без знакового бита — некоторые TSA не принимают отрицательный nonce
nonce[0] &= 0x7F;
- var request = System.Security.Cryptography.Pkcs.Rfc3161TimestampRequest.CreateFromHash(
- hash,
- new Oid(hashOid),
- nonce: nonce,
- requestSignerCertificates: true);
+ var generator = new TimeStampRequestGenerator();
+ generator.SetCertReq(true);
+ var request = generator.Generate(hashOid, hash, new BigInteger(1, nonce));
- using var content = new ByteArrayContent(request.Encode());
+ using var content = new ByteArrayContent(request.GetEncoded());
content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
HttpResponseMessage response;
try
{
- response = await Http.PostAsync(tsaUrl, content, cancellationToken);
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tsaUrl) { Content = content };
+ response = await Http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, deadline.Token);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
throw new InvalidOperationException(
@@ -75,17 +81,39 @@ public virtual async Task RequestTokenAsync(
throw new InvalidOperationException(
$"Служба штампов времени вернула ошибку HTTP {(int)response.StatusCode} ({tsaUrl}).");
- var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ const int maxBytes = 4 * 1024 * 1024;
+ if (response.Content.Headers.ContentLength > maxBytes)
+ throw new InvalidOperationException("Ответ TSA превышает допустимый размер.");
+ await using var input = await response.Content.ReadAsStreamAsync(deadline.Token);
+ using var output = new MemoryStream();
+ var buffer = new byte[8192];
+ int count;
+ while ((count = await input.ReadAsync(buffer, deadline.Token)) != 0)
+ {
+ if (output.Length + count > maxBytes) throw new InvalidOperationException("Ответ TSA превышает допустимый размер.");
+ output.Write(buffer, 0, count);
+ }
try
{
- var token = request.ProcessResponse(body, out _);
- return token.AsSignedCms().Encode();
+ return ValidateResponse(output.ToArray(), request, signatureValue);
}
- catch (CryptographicException e)
+ catch (Exception e) when (e is not OperationCanceledException)
{
throw new InvalidOperationException(
"Служба штампов времени вернула некорректный ответ: " + e.Message, e);
}
}
}
+
+ internal static byte[] ValidateResponse(byte[] responseBytes, TimeStampRequest request, byte[] signatureValue)
+ {
+ var response = new TimeStampResponse(responseBytes);
+ response.Validate(request); // status, nonce, policy, digest algorithm and message imprint
+ var token = response.TimeStampToken ?? throw new CryptographicException("TSA не вернула метку времени.");
+ var encoded = token.GetEncoded();
+ var check = SignatureVerifier.VerifyTimestampToken(encoded, signatureValue, VerificationOptions.CryptographyOnly);
+ if (check.Check.State != VerificationState.Valid) throw new CryptographicException(check.Check.Message);
+ // This proves token integrity. TSA trust/revocation is a separate result in the verification report.
+ return encoded;
+ }
}
diff --git a/src/SignService/Services/UpdateService.cs b/src/SignService/Services/UpdateService.cs
index dee39f4..bcdb830 100644
--- a/src/SignService/Services/UpdateService.cs
+++ b/src/SignService/Services/UpdateService.cs
@@ -148,3 +148,4 @@ @echo off
});
}
}
+
diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj
index 1719030..ee29604 100644
--- a/src/SignService/SignService.csproj
+++ b/src/SignService/SignService.csproj
@@ -10,7 +10,7 @@
true
SignService — подписание документов ЭЦП
SignService
- 1.0.0
+ 1.1.0
Зайнуллин Марат Илгамович
Зайнуллин Марат Илгамович
© 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com
@@ -26,6 +26,7 @@
+
diff --git a/src/SignService/ViewModels/CertificateItem.cs b/src/SignService/ViewModels/CertificateItem.cs
index 55c21b5..ed22bda 100644
--- a/src/SignService/ViewModels/CertificateItem.cs
+++ b/src/SignService/ViewModels/CertificateItem.cs
@@ -58,3 +58,4 @@ public CertificateItem(SavedCertificateInfo saved)
+ (IsExpired ? " (истёк)" : string.Empty)
+ (IsSaved ? " — сохранён на ПК 🔒" : string.Empty);
}
+
diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs
index 0d0ca81..0bb5873 100644
--- a/src/SignService/ViewModels/MainWindowViewModel.cs
+++ b/src/SignService/ViewModels/MainWindowViewModel.cs
@@ -154,6 +154,7 @@ private async Task CheckUpdatesOnStartAsync()
[NotifyCanExecuteChangedFor(nameof(ExtractCommand))]
[NotifyCanExecuteChangedFor(nameof(MergeFilesCommand))]
[NotifyCanExecuteChangedFor(nameof(BuildContainerCommand))]
+ [NotifyCanExecuteChangedFor(nameof(VerifySignatureCommand))]
private bool _isBusy;
[ObservableProperty]
@@ -204,6 +205,8 @@ private void Log(string message)
/// Запрос диалога выбора документа для сборки криптоконтейнера.
public event EventHandler? BuildContainerRequested;
+ public event EventHandler? VerifySignatureRequested;
+
///
/// Запрос пароля у пользователя (заголовок, сообщение, предупреждение или null,
/// требуется ли повторный ввод). Возвращает пароль или null при отмене.
@@ -313,7 +316,7 @@ public void SetPoa(string xmlPath, string sigPath, bool quiet = false)
try
{
var info = PowerOfAttorneyService.Parse(xmlPath, sigPath);
- var check = PowerOfAttorneyService.Validate(info, SelectedCertificate?.Certificate);
+ var check = PowerOfAttorneyService.Validate(info, SelectedCertificate?.Certificate, _settings.CreateVerificationOptions());
if (check.State == PowerOfAttorneyService.CheckState.Error)
{
@@ -670,6 +673,9 @@ public void AddFiles(params string[] filePaths)
[RelayCommand(CanExecute = nameof(CanBrowse))]
private void BuildContainer() => BuildContainerRequested?.Invoke(this, EventArgs.Empty);
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private void VerifySignature() => VerifySignatureRequested?.Invoke(this, EventArgs.Empty);
+
///
/// Собирает прикреплённый криптоконтейнер (документ + имеющиеся подписи)
/// без создания своей подписи.
@@ -852,19 +858,6 @@ private async Task SignAllAsync()
return;
}
- // Перед подписанием с МЧД перепроверяем её против фактического сертификата.
- if (Poa is { } poaCheck)
- {
- var check = PowerOfAttorneyService.Validate(poaCheck, certificate);
- if (check.State == PowerOfAttorneyService.CheckState.Error)
- {
- StatusText = "Подписание остановлено — проблема с МЧД: " + check.Message;
- return;
- }
- if (check.State == PowerOfAttorneyService.CheckState.Warning)
- Log("⚠ МЧД: " + check.Message);
- }
-
IsBusy = true;
var signed = 0;
@@ -872,6 +865,18 @@ private async Task SignAllAsync()
try
{
+ var poaVerificationOptions = Poa is null ? null : _settings.CreateVerificationOptions();
+ // Перед подписанием с МЧД перепроверяем её против фактического сертификата.
+ if (Poa is { } poaCheck)
+ {
+ var check = await Task.Run(() => PowerOfAttorneyService.Validate(poaCheck, certificate, poaVerificationOptions));
+ if (check.State == PowerOfAttorneyService.CheckState.Error)
+ {
+ StatusText = "Подписание остановлено — проблема с МЧД: " + check.Message;
+ return;
+ }
+ if (check.State == PowerOfAttorneyService.CheckState.Warning) Log("⚠ МЧД: " + check.Message);
+ }
foreach (var file in Files.Where(f => f.Status != SignStatus.Signed).ToList())
{
file.Status = SignStatus.Signing;
@@ -893,6 +898,7 @@ private async Task SignAllAsync()
StampWithDate = StampWithDate,
StampLogoPath = StampLogoPath,
PowerOfAttorney = Poa,
+ PoaVerificationOptions = poaVerificationOptions,
};
var result = await Task.Run(
() => _documentSigner.SignFileAsync(file.FilePath, certificate, options));
@@ -925,6 +931,11 @@ private async Task SignAllAsync()
}
}
}
+ catch (Exception ex)
+ {
+ StatusText = "Подписание остановлено: " + ex.Message;
+ return;
+ }
finally
{
IsBusy = false;
diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs
index 896428d..8715b34 100644
--- a/src/SignService/ViewModels/SignFileItem.cs
+++ b/src/SignService/ViewModels/SignFileItem.cs
@@ -104,3 +104,4 @@ public int AttachSignatures(IEnumerable sigPaths)
_ => $"{bytes / (1024.0 * 1024 * 1024):0.##} ГБ",
};
}
+
diff --git a/src/SignService/Views/AboutDialog.axaml b/src/SignService/Views/AboutDialog.axaml
index c799543..bc0346b 100644
--- a/src/SignService/Views/AboutDialog.axaml
+++ b/src/SignService/Views/AboutDialog.axaml
@@ -54,3 +54,4 @@
Margin="0,12,0,0" MinWidth="100" Name="CloseButton"/>
+
diff --git a/src/SignService/Views/AboutDialog.axaml.cs b/src/SignService/Views/AboutDialog.axaml.cs
index 74f0fbc..4d1047f 100644
--- a/src/SignService/Views/AboutDialog.axaml.cs
+++ b/src/SignService/Views/AboutDialog.axaml.cs
@@ -124,3 +124,4 @@ private static void OpenUrl(string url)
}
}
}
+
diff --git a/src/SignService/Views/ConfirmDialog.axaml b/src/SignService/Views/ConfirmDialog.axaml
index 8cf1c33..cf502c4 100644
--- a/src/SignService/Views/ConfirmDialog.axaml
+++ b/src/SignService/Views/ConfirmDialog.axaml
@@ -14,3 +14,4 @@
+
diff --git a/src/SignService/Views/ConfirmDialog.axaml.cs b/src/SignService/Views/ConfirmDialog.axaml.cs
index 3fc0adb..f5d76e9 100644
--- a/src/SignService/Views/ConfirmDialog.axaml.cs
+++ b/src/SignService/Views/ConfirmDialog.axaml.cs
@@ -19,3 +19,4 @@ public ConfirmDialog(string title, string message)
NoButton.Click += (_, _) => Close(false);
}
}
+
diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml
index a57b140..1d867df 100644
--- a/src/SignService/Views/MainWindow.axaml
+++ b/src/SignService/Views/MainWindow.axaml
@@ -218,6 +218,8 @@
ToolTip.Tip="Операции с подписями без подписания: извлечение, объединение, сборка контейнера, штамп">
+
+
diff --git a/tests/SignService.Tests/VerificationTests.cs b/tests/SignService.Tests/VerificationTests.cs
new file mode 100644
index 0000000..b8c2101
--- /dev/null
+++ b/tests/SignService.Tests/VerificationTests.cs
@@ -0,0 +1,382 @@
+using System.Text;
+using System.Formats.Asn1;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Cms;
+using Org.BouncyCastle.Asn1.CryptoPro;
+using Org.BouncyCastle.Asn1.Rosstandart;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Cms;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Ocsp;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Tsp;
+using Org.BouncyCastle.Utilities.Collections;
+using Org.BouncyCastle.X509;
+using SignService.Services;
+using Certificate = Org.BouncyCastle.X509.X509Certificate;
+using ContentInfo = Org.BouncyCastle.Asn1.Cms.ContentInfo;
+
+internal static class VerificationTests
+{
+ private static int _checks;
+ private static readonly DateTime Now = DateTime.UtcNow;
+ private static readonly byte[] Data = Encoding.UTF8.GetBytes("Подписанный документ: проверка без криптопровайдера");
+ private static readonly VerificationOptions Crypto = new()
+ { CheckCertificateTrust = false, CheckRevocation = false, UseSystemTrustStore = false };
+
+ public static async Task RunAsync(string temporaryRoot)
+ {
+ var rootKey = RsaKey();
+ var root = Cert("Root", rootKey, null, rootKey, 1, ca: true);
+ var leafKey = RsaKey();
+ var leaf = Cert("Signer", leafKey, root, rootKey, 2);
+ var otherKey = RsaKey();
+ var other = Cert("Other", otherKey, root, rootKey, 3);
+ var sig = Sign(Data, leaf, leafKey, new[] { leaf, root });
+ var trust = new VerificationOptions { UseSystemTrustStore = false, CheckRevocation = false,
+ TrustedRoots = new[] { root.GetEncoded() } };
+
+ var valid = SignatureVerifier.Verify(sig, Data, trust);
+ Assert(valid.CryptographicallyValid && valid.Signers[0].CertificateTrust.State == VerificationState.Valid, "RSA and PKIX trusted chain");
+ var untrusted = SignatureVerifier.Verify(sig, Data, new VerificationOptions { UseSystemTrustStore = false });
+ Assert(untrusted.CryptographicallyValid && untrusted.Signers[0].CertificateTrust.State == VerificationState.Unknown
+ && untrusted.Signers[0].Revocation.State == VerificationState.Unknown, "embedded root does not establish trust");
+ Assert(SignatureVerifier.Verify(sig, null, Crypto).Signers[0].Signature.State == VerificationState.NotChecked, "detached signature needs original");
+ Assert(!SignatureVerifier.Verify(sig, Data.Concat(new byte[] { 1 }).ToArray(), Crypto).CryptographicallyValid, "changed document rejected");
+ var damaged = Damage(sig, Data);
+ Assert(!SignatureVerifier.Verify(damaged, Data, Crypto).CryptographicallyValid, "changed signature rejected despite intact messageDigest");
+ var missingCert = Sign(Data, leaf, leafKey, new[] { root });
+ Assert(SignatureVerifier.Verify(missingCert, Data, Crypto).Signers[0].Signature.State == VerificationState.Unknown, "missing signer certificate unknown");
+ var unknownAlgorithm = UnknownDigest(sig);
+ Assert(SignatureVerifier.Verify(unknownAlgorithm, Data, Crypto).Signers[0].Signature.State == VerificationState.Unknown, "unsupported algorithm unknown rather than valid");
+ var otherSig = Sign(Data, other, otherKey, new[] { other, root });
+ var merged = CmsMerger.MergeForDocument(new[] { damaged, otherSig }, Data);
+ Assert(merged.ExcludedSigners.Count == 1 && merged.SignerCount == 1 && merged.UnverifiedSigners.Count == 0
+ && SignatureVerifier.Verify(merged.Signature, Data, Crypto).CryptographicallyValid, "damaged signer excluded from merge");
+
+ var oldData = Encoding.UTF8.GetBytes("Старая версия");
+ var oldAttached = Sign(oldData, leaf, leafKey, new[] { leaf }, attached: true);
+ var refreshed = CmsMerger.MergeForDocument(new[] { oldAttached, otherSig }, Data);
+ Assert(CmsMerger.ExtractContent(refreshed.Signature)!.SequenceEqual(Data)
+ && SignatureVerifier.Verify(refreshed.Signature, null, Crypto).CryptographicallyValid, "stale embedded document replaced");
+ ExpectFailure(() => CmsMerger.Merge(new[] { oldAttached, Sign(Data, other, otherKey, new[] { other }, attached: true) }),
+ "merging different embedded documents without an original is rejected");
+ var attached = Sign(Data, leaf, leafKey, new[] { leaf }, attached: true);
+ Assert(SignatureVerifier.Verify(attached, null, Crypto).CryptographicallyValid, "attached signature needs no external document");
+ Assert(!SignatureVerifier.Verify(attached, oldData, Crypto).CryptographicallyValid, "external and embedded document mismatch rejected");
+ var pem = Encoding.ASCII.GetBytes("-----BEGIN PKCS7-----\n" + Convert.ToBase64String(sig) + "\n-----END PKCS7-----");
+ Assert(SignatureVerifier.Verify(pem, Data, Crypto).CryptographicallyValid, "PEM accepted");
+ Assert(SignatureVerifier.Verify(new byte[] { 0x30, 0x84, 0xff, 0xff, 0xff, 0xff }, Data).Container.State == VerificationState.Invalid, "malformed length returns report");
+ var deep = new byte[] { 0x05, 0x00 };
+ for (var i = 0; i < 140; i++) deep = new byte[] { 0x30, 0x80 }.Concat(deep).Concat(new byte[] { 0, 0 }).ToArray();
+ Assert(SignatureVerifier.Verify(deep, Data).Container.State == VerificationState.Invalid, "deep ASN.1 rejected without stack overflow");
+ var wrongBinding = Sign(Data, leaf, leafKey, new[] { leaf }, bindingHash: new byte[32]);
+ Assert(SignatureVerifier.Verify(wrongBinding, Data, Crypto).Signers[0].CertificateBinding.State == VerificationState.Invalid,
+ "signed but incorrect ESSCertID rejected");
+ var noAttrs = Sign(Data, leaf, leafKey, new[] { leaf }, direct: true);
+ Assert(SignatureVerifier.Verify(noAttrs, Data, Crypto).CryptographicallyValid, "plain PKCS7 without signedAttrs supported");
+ Assert(!SignatureVerifier.Verify(noAttrs, oldData, Crypto).CryptographicallyValid, "direct signature over different bytes rejected");
+
+ var expired = Cert("Expired", leafKey, root, rootKey, 4, notAfter: Now.AddDays(-1));
+ var expiry = SignatureVerifier.Verify(Sign(Data, expired, leafKey, new[] { expired }), Data, trust);
+ Assert(expiry.CryptographicallyValid && expiry.Signers[0].CertificateTrust.State == VerificationState.Invalid, "expiry separate from mathematical validity");
+ var forbidden = Cert("No signing", leafKey, root, rootKey, 5, signingAllowed: false);
+ Assert(SignatureVerifier.Verify(Sign(Data, forbidden, leafKey, new[] { forbidden }), Data, trust).Signers[0].CertificateTrust.State == VerificationState.Invalid,
+ "key usage enforced");
+ var critical = Cert("Critical extension", leafKey, root, rootKey, 6, unknownCritical: true);
+ Assert(SignatureVerifier.Verify(Sign(Data, critical, leafKey, new[] { critical }), Data, trust).Signers[0].CertificateTrust.State != VerificationState.Valid,
+ "unknown critical certificate extension does not pass PKIX");
+
+ TestRevocation(root, rootKey, leaf, leafKey, sig, trust);
+ TestTimestamps(root, rootKey, leaf, leafKey, sig, trust);
+ TestGost();
+ await TestFiles(temporaryRoot);
+ using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
+ try { await SignatureVerifier.VerifyAsync(sig, Data, cancellationToken: cancelled.Token); throw new Exception("cancellation ignored"); }
+ catch (OperationCanceledException) { Assert(true, "verification cancellation preserved"); }
+ Console.WriteLine($"Managed verification regressions: {_checks} checks passed");
+ }
+
+ private static void TestRevocation(Certificate root, AsymmetricCipherKeyPair rootKey, Certificate leaf,
+ AsymmetricCipherKeyPair leafKey, byte[] sig, VerificationOptions trust)
+ {
+ VerificationState State(VerificationOptions options) => SignatureVerifier.Verify(sig, Data, options).Signers[0].Revocation.State;
+ var goodCrl = Crl(root, rootKey, null);
+ var crlOptions = trust with { CheckRevocation = true, Crls = new[] { goodCrl.GetEncoded() } };
+ Assert(State(crlOptions) == VerificationState.Valid, "fresh authenticated CRL good");
+ Assert(State(crlOptions with { Crls = new[] { Crl(root, rootKey, leaf).GetEncoded() } }) == VerificationState.Invalid, "CRL revoked");
+ Assert(State(crlOptions with { Crls = new[] { Crl(root, rootKey, null, stale: true).GetEncoded() } }) == VerificationState.Unknown, "stale CRL unknown");
+ Assert(State(crlOptions with { Crls = new[] { Crl(root, leafKey, null).GetEncoded() } }) == VerificationState.Unknown, "CRL forged with wrong key unknown");
+ Assert(State(crlOptions with { Crls = new[] { Crl(root, rootKey, null, delta: true).GetEncoded() } }) == VerificationState.Unknown, "delta CRL cannot falsely prove good");
+
+ var ocspOptions = trust with { CheckRevocation = true, OcspResponses = new[] { Ocsp(root, rootKey, leaf) } };
+ Assert(State(ocspOptions) == VerificationState.Valid, "fresh issuer OCSP good");
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(root, rootKey, leaf, revoked: true) } }) == VerificationState.Invalid, "OCSP revoked");
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(root, rootKey, leaf, stale: true) } }) == VerificationState.Unknown, "stale OCSP unknown");
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(root, leafKey, leaf) } }) == VerificationState.Unknown, "forged OCSP unknown");
+ var otherRootKey = RsaKey(); var otherRoot = Cert("Foreign root", otherRootKey, null, otherRootKey, 7, ca: true);
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(otherRoot, otherRootKey, leaf) } }) == VerificationState.Unknown,
+ "OCSP serial alone does not match a different issuer");
+ Assert(State(crlOptions with { OcspResponses = new[] { Ocsp(root, rootKey, leaf, revoked: true) } }) == VerificationState.Invalid,
+ "authenticated revoked overrides good evidence");
+ var responderKey = RsaKey();
+ var responder = Cert("OCSP delegate", responderKey, root, rootKey, 8, ocsp: true);
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(root, responderKey, leaf, responder: responder) } }) == VerificationState.Valid,
+ "authorized delegated OCSP responder");
+ var unauthorized = Cert("Unauthorized delegate", responderKey, root, rootKey, 9);
+ Assert(State(ocspOptions with { OcspResponses = new[] { Ocsp(root, responderKey, leaf, responder: unauthorized) } }) == VerificationState.Unknown,
+ "delegate without OCSP EKU rejected");
+ }
+
+ private static void TestTimestamps(Certificate root, AsymmetricCipherKeyPair rootKey, Certificate leaf,
+ AsymmetricCipherKeyPair leafKey, byte[] sig, VerificationOptions trust)
+ {
+ var tsaKey = RsaKey(); var tsa = Cert("TSA", tsaKey, root, rootKey, 10, tsa: true);
+ var signatureValue = CmsMerger.GetSignatureValue(sig);
+ var generator = new TimeStampRequestGenerator(); generator.SetCertReq(true);
+ var request = generator.Generate("2.16.840.1.101.3.4.2.1", DigestUtilities.CalculateDigest("SHA-256", signatureValue), BigInteger.ValueOf(123));
+ var signerInfo = new SignerInfoGeneratorBuilder().Build(new Asn1SignatureFactory("SHA256WITHRSA", tsaKey.Private), tsa);
+ var tokenGenerator = new TimeStampTokenGenerator(signerInfo, Asn1DigestFactory.Get(new DerObjectIdentifier("2.16.840.1.101.3.4.2.1")), new DerObjectIdentifier("1.2.3.4"), false);
+ tokenGenerator.SetCertificates(CollectionUtilities.CreateStore(new[] { tsa, root }));
+ var responseGenerator = new TimeStampResponseGenerator(tokenGenerator, new[] { "2.16.840.1.101.3.4.2.1", "1.2.643.7.1.1.2.2" });
+ var response = responseGenerator.Generate(request, BigInteger.One, Now.AddMinutes(-1));
+ var token = TimestampClient.ValidateResponse(response.GetEncoded(), request, signatureValue);
+ var withTime = CmsMerger.AddUnsignedAttribute(sig, TimestampClient.TimeStampTokenOid, token);
+ Assert(SignatureVerifier.Verify(withTime, Data, trust).Signers[0].Timestamp.State == VerificationState.Valid, "trusted TSA token validated");
+ var untrusted = trust with { TrustedRoots = Array.Empty() };
+ Assert(SignatureVerifier.Verify(withTime, Data, untrusted).Signers[0].Timestamp.State == VerificationState.Unknown, "untrusted TSA token never establishes time");
+ var tokenBad = Damage(token, null);
+ Assert(SignatureVerifier.Verify(CmsMerger.AddUnsignedAttribute(sig, TimestampClient.TimeStampTokenOid, tokenBad), Data, trust)
+ .Signers[0].Timestamp.State == VerificationState.Invalid, "damaged timestamp signature rejected");
+ Assert(SignatureVerifier.Verify(CmsMerger.AddUnsignedAttribute(Sign(Data, leaf, leafKey, new[] { leaf }),
+ TimestampClient.TimeStampTokenOid, token), Data.Concat(new byte[] { 1 }).ToArray(), trust).CryptographicallyValid == false,
+ "timestamp cannot repair wrong document");
+ var other = Sign(Data.Concat(new byte[] { 7 }).ToArray(), leaf, leafKey, new[] { leaf });
+ Assert(SignatureVerifier.VerifyTimestampToken(token, CmsMerger.GetSignatureValue(other), trust).Check.State == VerificationState.Invalid,
+ "TSA imprint bound to exact signature value");
+ var wrongNonce = generator.Generate("2.16.840.1.101.3.4.2.1", request.GetMessageImprintDigest(), BigInteger.ValueOf(456));
+ ExpectFailure(() => TimestampClient.ValidateResponse(response.GetEncoded(), wrongNonce, signatureValue), "TSA nonce mismatch rejected");
+ var gostRequest = generator.Generate("1.2.643.7.1.1.2.2", DigestUtilities.CalculateDigest("1.2.643.7.1.1.2.2", signatureValue), BigInteger.ValueOf(789));
+ var gostResponse = responseGenerator.Generate(gostRequest, BigInteger.Two, Now.AddMinutes(-1));
+ Assert(TimestampClient.ValidateResponse(gostResponse.GetEncoded(), gostRequest, signatureValue).Length > 0,
+ "TSA GOST imprint processed without CryptoPro");
+ ExpectFailure(() => TimestampClient.ValidateResponse(response.GetEncoded(), gostRequest, signatureValue), "TSA algorithm/imprint mismatch rejected");
+ var futureToken = tokenGenerator.Generate(request, BigInteger.ValueOf(12), Now.AddDays(1)).GetEncoded();
+ Assert(SignatureVerifier.VerifyTimestampToken(futureToken, signatureValue, trust).Check.State == VerificationState.Invalid, "future TSA time rejected");
+ var expired = Cert("Historically valid signer", leafKey, root, rootKey, 12, notAfter: Now.AddHours(-1));
+ var expiredSig = Sign(Data, expired, leafKey, new[] { expired, root });
+ var historicalRequest = generator.Generate("2.16.840.1.101.3.4.2.1", DigestUtilities.CalculateDigest("SHA-256", CmsMerger.GetSignatureValue(expiredSig)), BigInteger.ValueOf(13));
+ var historicalToken = tokenGenerator.Generate(historicalRequest, BigInteger.ValueOf(13), Now.AddHours(-2)).GetEncoded();
+ var historical = CmsMerger.AddUnsignedAttribute(expiredSig, TimestampClient.TimeStampTokenOid, historicalToken);
+ Assert(SignatureVerifier.Verify(historical, Data, trust).Signers[0].CertificateTrust.State == VerificationState.Valid,
+ "only trusted timestamp permits historical certificate validity");
+ Assert(SignatureVerifier.Verify(historical, Data, untrusted).Signers[0].CertificateTrust.State == VerificationState.Invalid,
+ "untrusted timestamp cannot bypass current certificate expiry");
+ }
+
+ private static void TestGost()
+ {
+ foreach (var bits in new[] { 2001, 256, 512 })
+ {
+ var curveOid = bits == 2001 ? CryptoProObjectIdentifiers.GostR3410x2001CryptoProA
+ : bits == 256 ? RosstandartObjectIdentifiers.id_tc26_gost_3410_12_256_paramSetA
+ : RosstandartObjectIdentifiers.id_tc26_gost_3410_12_512_paramSetA;
+ var digestOid = bits == 2001 ? CryptoProObjectIdentifiers.GostR3411x94CryptoProParamSet
+ : bits == 256 ? RosstandartObjectIdentifiers.id_tc26_gost_3411_12_256 : RosstandartObjectIdentifiers.id_tc26_gost_3411_12_512;
+ var curve = ECGost3410NamedCurves.GetByOid(curveOid);
+ var parameters = new ECGost3410Parameters(new ECNamedDomainParameters(curveOid, curve), curveOid, digestOid, null);
+ var keyGenerator = new ECKeyPairGenerator("ECGOST3410");
+ keyGenerator.Init(new ECKeyGenerationParameters(parameters, new SecureRandom()));
+ var pair = keyGenerator.GenerateKeyPair();
+ var algorithm = bits == 2001 ? "GOST3411WITHECGOST3410" : $"GOST3411-2012-{bits}WITHECGOST3410-2012-{bits}";
+ var cert = Cert("GOST " + bits, pair, null, pair, bits, algorithm: algorithm);
+ var encoded = Sign(Data, cert, pair, new[] { cert }, algorithm: algorithm);
+ Assert(SignatureVerifier.Verify(encoded, Data, Crypto).CryptographicallyValid, "GOST " + bits + " full managed verification");
+ Assert(new DocumentSigner().VerifyDetached(Data, encoded), "GOST " + bits + " compatibility API");
+ Assert(!SignatureVerifier.Verify(Damage(encoded, Data), Data, Crypto).CryptographicallyValid, "GOST " + bits + " damaged value rejected");
+ Assert(!SignatureVerifier.Verify(encoded, Data.Concat(new byte[] { 1 }).ToArray(), Crypto).CryptographicallyValid, "GOST " + bits + " changed document rejected");
+ var root = Cert("GOST root " + bits, pair, null, pair, bits + 100, ca: true, algorithm: algorithm);
+ var leafPair = keyGenerator.GenerateKeyPair();
+ var leaf = Cert("GOST leaf " + bits, leafPair, root, pair, bits + 200, algorithm: algorithm);
+ var trust = new VerificationOptions { UseSystemTrustStore = false, CheckRevocation = false, TrustedRoots = new[] { root.GetEncoded() } };
+ var chained = SignatureVerifier.Verify(Sign(Data, leaf, leafPair, new[] { leaf, root }, algorithm: algorithm), Data, trust);
+ Assert(chained.CryptographicallyValid && chained.Signers[0].CertificateTrust.State == VerificationState.Valid,
+ "GOST " + bits + " certificate chain verified without CSP");
+ }
+ var ec = new ECKeyPairGenerator();
+ ec.Init(new ECKeyGenerationParameters(Org.BouncyCastle.Asn1.Sec.SecObjectIdentifiers.SecP256r1, new SecureRandom()));
+ var ecPair = ec.GenerateKeyPair();
+ var ecCert = Cert("ECDSA", ecPair, null, ecPair, 21, algorithm: "SHA256WITHECDSA");
+ Assert(SignatureVerifier.Verify(Sign(Data, ecCert, ecPair, new[] { ecCert }, algorithm: "SHA256WITHECDSA"), Data, Crypto).CryptographicallyValid, "ECDSA verification");
+ }
+
+ private static async Task TestFiles(string directory)
+ {
+ var files = Path.Combine(directory, "verification_regressions"); Directory.CreateDirectory(files);
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest("CN=Representative, OID.1.2.643.3.131.1.1=123456789012, OID.1.2.643.100.3=12345678901", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ using var representative = request.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(1));
+ using var headRsa = RSA.Create(2048);
+ using var head = new CertificateRequest("CN=Head", headRsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
+ .CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(1));
+ var signer = new DocumentSigner();
+ var document = Path.Combine(files, "document.bin"); await File.WriteAllBytesAsync(document, Data);
+ var sig = signer.Sign(Data, head, detached: false); await File.WriteAllBytesAsync(document + ".sig", sig);
+ var result = await signer.SignFileAsync(document, representative, new DocumentSigner.SignOptions { Detached = true, MergeWithExisting = true });
+ Assert(CmsMerger.ExtractContent(await File.ReadAllBytesAsync(result.SignaturePath)) is null && result.SignerCount == 2,
+ "selected detached mode preserved when input is attached");
+ Assert(signer.VerifyDetached(Data, await File.ReadAllBytesAsync(result.SignaturePath)), "merged detached signatures verify");
+
+ var poaDirectory = Path.Combine(files, "poa"); Directory.CreateDirectory(poaDirectory);
+ var xmlPath = Path.Combine(poaDirectory, "power.xml"); var sigPath = xmlPath + ".sig";
+ string Xml(DateTime validTo, string snils = "12345678901") => $"<Доверенность><СвДов НомДовер=\"123\" ДатаВыдДовер=\"{DateTime.Today.AddDays(-1):yyyy-MM-dd}\" СрокДейст=\"{validTo:yyyy-MM-dd}\"/><СвРосОрг ИННЮЛ=\"1234567890\"/><СвУпПред><СведФизЛ ИННФЛ=\"123456789012\" СНИЛС=\"{snils}\"><ФИО Фамилия=\"Представитель\"/>СведФизЛ>СвУпПред>Доверенность>";
+ async Task WritePoa(string xml)
+ {
+ await File.WriteAllTextAsync(xmlPath, xml);
+ await File.WriteAllBytesAsync(sigPath, signer.Sign(await File.ReadAllBytesAsync(xmlPath), head));
+ }
+ await WritePoa(Xml(DateTime.Today.AddDays(10)));
+ var selected = PowerOfAttorneyService.Parse(xmlPath, sigPath);
+ Assert(PowerOfAttorneyService.Validate(selected, representative).State == PowerOfAttorneyService.CheckState.Warning, "untrusted POA head warns");
+ using var expiredHead = new CertificateRequest("CN=Expired head", headRsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
+ .CreateSelfSigned(DateTimeOffset.Now.AddDays(-5), DateTimeOffset.Now.AddDays(-1));
+ await File.WriteAllBytesAsync(sigPath, signer.Sign(await File.ReadAllBytesAsync(xmlPath), expiredHead));
+ Assert(PowerOfAttorneyService.Validate(selected, head).State == PowerOfAttorneyService.CheckState.Error,
+ "missing representative identifiers cannot skip invalid head certificate");
+ await WritePoa(Xml(DateTime.Today.AddDays(10)));
+ var xmlWithSecondRep = Xml(DateTime.Today.AddDays(10)).Replace("<СвУпПред>",
+ "<СвУпПред><СведФизЛ ИННФЛ=\"999999999999\" СНИЛС=\"99999999999\"><ФИО Фамилия=\"Другой\"/>СведФизЛ>СвУпПред><СвУпПред>");
+ await WritePoa(xmlWithSecondRep);
+ Assert(PowerOfAttorneyService.Prepare(selected, representative).Info.RepresentativeInn == "123456789012",
+ "representative selected by identifiers in multi-representative POA");
+ await WritePoa(Xml(DateTime.Today.AddDays(10)));
+ var headSig = await File.ReadAllBytesAsync(sigPath);
+ await File.WriteAllBytesAsync(sigPath, Damage(headSig, await File.ReadAllBytesAsync(xmlPath)));
+ Assert(PowerOfAttorneyService.Validate(selected, representative).State == PowerOfAttorneyService.CheckState.Error, "POA damaged signature rejected with same hash");
+ await WritePoa(Xml(DateTime.Today.AddDays(-10)));
+ Assert(PowerOfAttorneyService.Validate(selected, representative).State == PowerOfAttorneyService.CheckState.Error, "cached POA metadata cannot bypass expired replacement");
+ await WritePoa(Xml(DateTime.Today.AddDays(10), "99999999999"));
+ Assert(PowerOfAttorneyService.Validate(selected, representative).State == PowerOfAttorneyService.CheckState.Error, "matching INN cannot hide conflicting SNILS");
+ await WritePoa(Xml(DateTime.Today.AddDays(10)));
+ var preserved = await File.ReadAllBytesAsync(document + ".sig");
+ var collision = Path.Combine(files, "power.xml"); await File.WriteAllTextAsync(collision, "unrelated existing file");
+ try { await signer.SignFileAsync(document, representative, new DocumentSigner.SignOptions { PowerOfAttorney = selected }); throw new Exception("collision ignored"); }
+ catch (IOException) { Assert(true, "POA filename collision fails before signature publication"); }
+ Assert((await File.ReadAllBytesAsync(document + ".sig")).SequenceEqual(preserved)
+ && await File.ReadAllTextAsync(collision) == "unrelated existing file", "collision leaves existing signature and POA intact");
+ File.Delete(collision);
+ var package = PowerOfAttorneyService.Prepare(selected, representative);
+ await WritePoa(Xml(DateTime.Today.AddDays(-10)));
+ PowerOfAttorneyService.CopyPackage(package, document);
+ Assert((await File.ReadAllBytesAsync(collision)).SequenceEqual(package.Xml), "POA copy uses verified snapshot after source replacement");
+ await File.WriteAllTextAsync(xmlPath, "]><Доверенность>&x;Доверенность>");
+ Assert(PowerOfAttorneyService.Validate(selected, representative).State == PowerOfAttorneyService.CheckState.Error, "DTD and external entities rejected");
+ using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
+ try { await AtomicFile.WriteAsync(document + ".sig", new byte[] { 1 }, cancellationToken: cancelled.Token); throw new Exception("write cancellation ignored"); }
+ catch (OperationCanceledException) { Assert(true, "atomic write cancelled"); }
+ Assert((await File.ReadAllBytesAsync(document + ".sig")).SequenceEqual(preserved)
+ && Directory.GetFiles(files, ".signservice-*.tmp").Length == 0, "cancelled atomic write preserves file and cleans temp");
+ }
+
+ private static AsymmetricCipherKeyPair RsaKey()
+ {
+ var generator = new RsaKeyPairGenerator(); generator.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ return generator.GenerateKeyPair();
+ }
+
+ private static Certificate Cert(string name, AsymmetricCipherKeyPair key, Certificate? issuer,
+ AsymmetricCipherKeyPair issuerKey, int serial, bool ca = false, bool tsa = false, bool ocsp = false,
+ DateTime? notAfter = null, bool signingAllowed = true, bool unknownCritical = false, string algorithm = "SHA256WITHRSA")
+ {
+ var generator = new X509V3CertificateGenerator();
+ generator.SetSerialNumber(BigInteger.ValueOf(serial));
+ generator.SetSubjectDN(new X509Name("CN=" + name)); generator.SetIssuerDN(issuer?.SubjectDN ?? new X509Name("CN=" + name));
+ generator.SetNotBefore(Now.AddDays(-5)); generator.SetNotAfter(notAfter ?? Now.AddYears(1)); generator.SetPublicKey(key.Public);
+ generator.AddExtension(X509Extensions.BasicConstraints, true, new BasicConstraints(ca));
+ generator.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(ca ? KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.DigitalSignature
+ : signingAllowed ? KeyUsage.DigitalSignature : KeyUsage.KeyEncipherment));
+ if (tsa) generator.AddExtension(X509Extensions.ExtendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeID.id_kp_timeStamping));
+ if (ocsp) generator.AddExtension(X509Extensions.ExtendedKeyUsage, true, new ExtendedKeyUsage(KeyPurposeID.id_kp_OCSPSigning));
+ if (unknownCritical) generator.AddExtension(new DerObjectIdentifier("1.2.3.4.5.6.7"), true, DerNull.Instance);
+ return generator.Generate(new Asn1SignatureFactory(algorithm, issuerKey.Private));
+ }
+
+ private static byte[] Sign(byte[] data, Certificate cert, AsymmetricCipherKeyPair key, Certificate[] certs,
+ bool attached = false, bool direct = false, byte[]? bindingHash = null, string algorithm = "SHA256WITHRSA")
+ {
+ var generator = new CmsSignedDataGenerator(); var builder = new SignerInfoGeneratorBuilder();
+ if (direct) builder.SetDirectSignature(true);
+ if (bindingHash is not null)
+ {
+ var id = new Org.BouncyCastle.Asn1.Ess.EssCertIDv2(bindingHash);
+ var attr = new Org.BouncyCastle.Asn1.Cms.Attribute(new DerObjectIdentifier(CadesAttributes.SigningCertificateV2Oid),
+ new DerSet(new Org.BouncyCastle.Asn1.Ess.SigningCertificateV2(new[] { id })));
+ builder.WithSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(new Org.BouncyCastle.Asn1.Cms.AttributeTable(new DerSet(attr))));
+ }
+ generator.AddSignerInfoGenerator(builder.Build(new Asn1SignatureFactory(algorithm, key.Private), cert));
+ generator.AddCertificates(CollectionUtilities.CreateStore(certs));
+ return generator.Generate(new CmsProcessableByteArray(data), attached).GetEncoded();
+ }
+
+ private static byte[] Damage(byte[] encoded, byte[]? data)
+ {
+ var cms = data is null ? new CmsSignedData(encoded) : new CmsSignedData(new CmsProcessableByteArray(data), encoded);
+ var value = cms.GetSignerInfos().GetSigners().First().GetSignature();
+ var copy = encoded.ToArray(); var offset = copy.AsSpan().IndexOf(value);
+ if (offset < 0) throw new Exception("Signature value missing");
+ copy[offset + value.Length - 1] ^= 1; return copy;
+ }
+
+ private static byte[] UnknownDigest(byte[] encoded)
+ {
+ var content = ContentInfo.GetInstance(Asn1Object.FromByteArray(encoded));
+ var signed = SignedData.GetInstance(content.Content);
+ var signer = SignerInfo.GetInstance(signed.SignerInfos[0]);
+ var unknown = new AlgorithmIdentifier(new DerObjectIdentifier("1.2.3.4.999"));
+ var changed = new SignerInfo(signer.SignerID, unknown, signer.SignedAttrs,
+ signer.SignatureAlgorithm, signer.Signature, signer.UnsignedAttrs);
+ return new ContentInfo(content.ContentType, new SignedData(new DerSet(unknown), signed.EncapContentInfo,
+ signed.Certificates, signed.CRLs, new DerSet(changed))).GetEncoded();
+ }
+
+ private static X509Crl Crl(Certificate issuer, AsymmetricCipherKeyPair key, Certificate? revoked, bool stale = false, bool delta = false)
+ {
+ var generator = new X509V2CrlGenerator(); generator.SetIssuerDN(issuer.SubjectDN);
+ generator.SetThisUpdate(Now.AddHours(-2)); generator.SetNextUpdate(stale ? Now.AddHours(-1) : Now.AddDays(1));
+ if (revoked is not null) generator.AddCrlEntry(revoked.SerialNumber, Now.AddHours(-3), CrlReason.KeyCompromise);
+ if (delta) generator.AddExtension(X509Extensions.DeltaCrlIndicator, true, DerInteger.ValueOf(1));
+ return generator.Generate(new Asn1SignatureFactory("SHA256WITHRSA", key.Private));
+ }
+
+ private static byte[] Ocsp(Certificate issuer, AsymmetricCipherKeyPair key, Certificate cert,
+ bool revoked = false, bool stale = false, Certificate? responder = null)
+ {
+ var generator = new BasicOcspRespGenerator((responder ?? issuer).GetPublicKey());
+ var id = new CertificateID(new AlgorithmIdentifier(new DerObjectIdentifier("1.3.14.3.2.26")), issuer, cert.SerialNumber);
+ generator.AddResponse(id, revoked ? new RevokedStatus(Now.AddHours(-3), CrlReason.KeyCompromise) : null,
+ Now.AddHours(-2), stale ? Now.AddHours(-1) : Now.AddDays(1), null);
+ var basic = generator.Generate(new Asn1SignatureFactory("SHA256WITHRSA", key.Private), new[] { responder ?? issuer }, Now.AddMinutes(-1));
+ return new OcspResp(new Org.BouncyCastle.Asn1.Ocsp.OcspResponse(new Org.BouncyCastle.Asn1.Ocsp.OcspResponseStatus(0),
+ new Org.BouncyCastle.Asn1.Ocsp.ResponseBytes(Org.BouncyCastle.Asn1.Ocsp.OcspObjectIdentifiers.PkixOcspBasic,
+ new DerOctetString(basic.GetEncoded())))).GetEncoded();
+ }
+
+ private static void Assert(bool condition, string scenario)
+ {
+ if (!condition) throw new Exception("Regression failed: " + scenario);
+ _checks++; Console.WriteLine("verification: " + scenario + ": OK");
+ }
+
+ private static void ExpectFailure(Action action, string scenario)
+ {
+ try { action(); }
+ catch (Exception) { Assert(true, scenario); return; }
+ throw new Exception("Expected rejection: " + scenario);
+ }
+}
diff --git a/tests/SignService.Tests/streebog_vectors.json b/tests/SignService.Tests/streebog_vectors.json
index 9ba0f03..150dae3 100644
--- a/tests/SignService.Tests/streebog_vectors.json
+++ b/tests/SignService.Tests/streebog_vectors.json
@@ -1 +1 @@
-[{"data": "303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132", "h256": "9d151eefd8590b89daa6ba6cb74af9275dd051026bb149a452fd84e5e57b5500", "h512": "1b54d01a4af5b9d5cc3d86d68d285462b19abc2475222f35c085122be4ba1ffa00ad30f8767b3a82384c6574f024c311e2a481332b08ef7f41797891c1646f48"}, {"data": "d1e520e2e5f2f0e82c20d1f2f0e8e1eee6e820e2edf3f6e82c20e2e5fef2fa20f120eceef0ff20f1f2f0e5ebe0ece820ede020f5f0e0e1f0fbff20efebfaeafb20c8e3eef0e5e2fb", "h256": "9dd2fe4e90409e5da87f53976d7405b0c0cac628fc669a741d50063c557e8f50", "h512": "1e88e62226bfca6f9994f1f2d51569e0daf8475a3b0fe61a5300eee46d961376035fe83549ada2b8620fcd7c496ce5b33f0cb9dddc2b6460143b03dabac9fb28"}, {"data": "", "h256": "3f539a213e97c802cc229d474c6aa32a825a360b2a933a949fd925208d9ce1bb", "h512": "8e945da209aa869f0455928529bcae4679e9873ab707b55315f56ceb98bef0a7362f715528356ee83cda5f2aac4c6ad2ba3a715c1bcd81cb8e9f90bf4c1c1a8a"}, {"data": "00", "h256": "6f7305265dc0937440881f9493ef1260f61a9d47742d369e952d41bdb2a9edd1", "h512": "c6b638133ba9706410ddf1bea05d40bf7014500d410c0abde17bff0383c1bd363be2da85c428be86ed48c87fb76013622b22b6aa391d6252ce3a65487b1ba9e4"}, {"data": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "4efe4b89530a0fc90f8c440296ec19ac987b61e8e4e9870d06274a1408237333", "h512": "5bfc84a15cc67a2cd0bbaf7b67e34c239f9cccc89d4798354fdc27ba0a541bb225d2729b5dc56d8ad3720f1c74932978bb50d32a9841bedeb926b682ec97cf97"}, {"data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "df1fda9ce83191390537358031db2ecaa6aa54cd0eda241dc107105e13636b95", "h512": "b0fd29ac1b0df441769ff3fdb8dc564df67721d6ac06fb28ceffb7bbaa7948c6c014ac999235b58cb26fb60fb112a145d7b4ade9ae566bf2611402c552d20db7"}, {"data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "ff494da4e950940619b06db49c4c3dac03a3823e134c22ff0b732599c85b321f", "h512": "a673ba3cb0e06fdbdc2ea86e3600f1deaff1008894c1f248b8a825302d9d4995f4bb73145967aa4d7b3ec0ff5157b91ee57dd4bc77fa29aaa89ccda5be1465b5"}, {"data": "5a", "h256": "2672bee10ba0e1edc145137763f2fce1a932e128f9928fdb4879e6adb13eae05", "h512": "685d1acf4098a513218190577cb022c5738811ff60df9d7008a8cd726ab860d13b3a4fe56b9eeb662a7be2f74d9381531ce9f3b85d7f76f6e449cbea32370b7c"}, {"data": "bc01b0499da8602ea430a380b9ceacec28789b370433183a8a547b1cb08b629692e172b159a647bbed632e698794806e244411db5a8071e7487aa19c50cc561b5cb8cd067a111f6723032aa81114e0ae357012535ad8fe48207db76e2b7775866ead1e20c03f87caa826767426eb5c3a0c2399aec3b36ae5c9c4b4bda02513", "h256": "049233f88f3fdc695c300d8723a7417a71a559866519107dc404550273b9bb25", "h512": "81320b4b2a39341a44b54fb87da7feccea214cb5f196e1a83ba9042035dad0091ba77fec7402daaff63e38b015d31bd4706ec22badac0c13c09d34080107e068"}, {"data": "cc803f7a9137b5ea0bf6e85708772a88e60f1434e069fedf5beb9cbf72d0103822d93c8721b465f887dbe51887693b2cae772c4cd2f41766c9b66bff3b8e8df9b904995ea8296af8bd5b1d70921e8c69db07d9580783884f01c3e165f9ffd55a50025d57258bce36816fd72acf6ef861c03959362bb8750996916df7982c88ad", "h256": "77c2147401b499f521a8fe40b685c31dfdd6b2256cb6f5046d41917d153ac338", "h512": "b0f07211ceab3acf3ecc9ba7368d802ffc7c97b58f332995d39fe3f82f8461762bcf1ade6b80c0d7fabc3f0072df7e5dbcabebc8671c1d1b14dd6e84349e3f56"}, {"data": "58af547930aff8da9b0b43963217982fac8f847f724007f1223af0644d07ce00fa04d7f40a4b49fef3651a6f96d3820b924843290e969bb3adfe17012374c8c49d828865a0868f4f1399c8e0e4535e8b334707a27df6a34bf7c05837abbd6ce474bed5a01a6dabded6b893b24d61965efcfb6136012f38dde2a8d626fe40566ee61658250d0563403b2c576b4c370fb75c679f1dc242d58f60a2841588ca07e63a09c3f584cae0296101b6e07035e5a96f869dec345c09bc69b80871bd33e04b03a1fed988846e325f393333b7993c02a9e85bffa4892e8c850e220d710e23a30c28337208c4d21d2977c6e702519720f037c81c31da51888ced5f3e371a8b8d8604e620a4b067d23471f7f9a18d4686dc83387f0a3f5a3393c9a19303887ffbf765188a5259e4a9564bf890510fbdc43d2bdc8046ead5904deabfed34c601565f7fda8af1555c407a2e47c4ea7c9bef966404c58d2c311d5758eeb853fc0396d48338897b0ae70025f5699953c7f887cd773339a39e7605ffe65e68d4c1d8bc599ba062ce0e29b547c3d1ff2dcdf49848f13ac0f05057b7b9648e545c7853cbb429f9c365e18e6a67fa9316a83d6f15265753d0ac1319601bb7964b8c40d9a085b2d0ee910bf8b692ddb1c251fc9b628b1ad44474c1225e31ed61d49e01913a8af2e17596bc0c37bb91f2c45ecd47cf78772ec28232b414faab372312511edf6609ef0242b258a8122624654afc9c7d857ed1ee1453f418373c474c2f02ec4458b6950fa6fcfec1a20ea2a2b95cb87cd2a7a11b83391aa3e9bffc93c2d1aeb523040132b40b4b97b711d4daabbac5014b3b1f113d7aa6ca371aa312203d41b09725e13ac96cc2a89cc96c35b6bc955545fbc2c16c1a0ecc14937343b1fcaca227956f6fc1252e030dc3eea3e747efcc7855e928466171d13633c3257bce422ef3ed03944f0803ee89616532851098fdb9470217ff3e70d13854a076124bede6225c766bcaec820e8553d77c0a02e8eca9f29142c361650e7766f941001934b33c6c3e63f8d6141597225bd5b06e3ce75a27f00f9b74108b53dddd1356ea95bc5feb7eaffc8b5b04fb4de573e0b509f3d40cc7547ef883a2556c4228ffdf3e5d0b0723b02d3e9af4f2f485e66f5d6c64df27aed2b9a9aaabc52b2fad6f7524fac9d01559f09f8c75fd83f1cd1f72897bd91f6a466e0a1042f7d0a497cbcedec181c5da7ed59cef7373caec08cb7a839ca22d4e3980a9cd91f9826fe2da10721d4862445e7f11e94bec11707e8d927bbb523e1fb97846d72598420155fe9ef98e86231263eb8a3ae8449a687b4766bdbea5f16e95bc010e88cee042dd919cb88f0ec00aa91ff41f9a93e639f6a4979f697225ac19decba47ffa774c078b70aa3f1e3487db480a2a82", "h256": "ef3532fda5f4d85901285f3e9957b30ad157718985b27218294f79b7d121e1a3", "h512": "0d697609f4ad12a5dcfbd6d0c23c43754d5ae176a2dcf114f5e5ec940352927d63fb3c9512d5dd3d1f4da82a4ebfe2468afa19ec4489674b68288905d4f02292"}, {"data": "8ebe2f03649da0063143af3dd9392943d9c739bf0d4dd0b035b17c8b27a2ba095d994869d834823c420ab004350e748fe6330b8ce0c65fbca197749d06eee0454ca593f6b8094aadb000779d307298ff91386fac86ca349a94ee957b9c4282985cfea9178c50874eb49c3574336368df6e95d8d867f841941eafb831fcf42712731d7a30ef48dc6bb9d56ee4936cb0ad5bc6db58a9560e753094e13705f2f79c14e55811049ddfc14910d967aec3ab4fdcb11c55e10947df0d9679d26ec06a76856a0bdca1a922ff282324715e0d7bf773b945ff8bb9ab0eddbf51a974060a578077902e9791ab3dd43748720d2b9990f1810d0ae0855fbb4a273e3b007181224a283b7c2082b70b8d4ce1cfe0bab2437e95f5e849cd6231bab4d139411dc0b7b561098114ae732d8348b45d573f31621c5a8a69bbec14de8e29379593039c88661d86f569680ffdfbaa6bcab29509659ad08f328479d14387cce4152ca3a2460ecf0a1ae30cbe36c6f28dcc724c98a0db6ddbef9d95340448e7e9fd375c5aa1c8056e2bb169c5f23f0251215e8d115dd1f92c9039a52b861e872b70f58f4fb8776e3707517be65475a123199ad467dbfbcdbe0aea51b287ab2db9788587069db3915d1931a43810b9141a66bc3194bcc152d8819657f903d95b8cf3af0778fff6942e213727442def6a62ae5ad15ba0805aea914d36879a29f30d62f67a036d6463502878ce5e8ac199ffc5fdbf9df3ce35bdc6e3ef90da7c21b1113b989380b13f9dcb1c051cc5ef0bb2c85a68e06a5ce41ae0af29310e5e23eab173b5ec7deab80019faf953e3a506b0a1a92ab9889016371438f1baf215171d00009389ac1381eacc686f8dddf9776fd21b31cc7d8fc9971a2ac7bb2c2e7ced26ce279fc7256ab2f39d1c85882bc3a5bd114555e36e0032b307ce7426da0e01dbf33d748cd669ffb85f371b12d45a7637fd7b485a3c98f2af4f1a8bbce1138764306324e1a096f142170a01cc50bd9c217788a9e6116476743bcdc721ef8a561c175e15b89dee2a2db0ca822e2f6d118051a24ff9e26f378f8be1ad4d00399def2d6a1266dbb9ac56efbdf3fcfc5ce7eae2a6a9b83958b7325ac0baffd0d67ee0923861e14c80fd5552f6d4f12c8bc5e24b5716a8edc072c5c16e8a8a6d9c7e15b3144b0707fb67ed249901b0c9a92983305685f6a7e086bd0fe8c976bb8dd79404ef0f4b2f4aedbb618ac9da33d5bbca752da0dea67ce9424fd65a37ae2c89a6564a4492413cb40b59b7c65a3bc802a3334e38f69ec13a99c491ad5c00b173faaef574e1d4335077afa2225290ff1679175d4e992a581758374634b3f37d7dfa3b54851c6a1732f849d644331f6225ad8bb95eacfade5932f3a5f0224749dcd511c1c6309408f4a54f367958178d759e1b95822a187e8ecaf1b5fdf3619c369d32589f4955b8aff860ea9ae50080cd8a404bd3236a19ea53daba2159976e36ad4ccdb290bd56f98dda59a4f70683dcc50c3d2b7966e410991ea02f30ed5071cd23baf7d542e864d3b9121a13b0a1217e11d4332350530a6846f29978d1527f190a4cb25fd26cca4a2e1a70e1be3893c0336bb6647fb8476fda28ef40bbc8bdb809a8d5548c9c2e4bda5718d43d2854ed9121e3cfc3e23618f6710afee7d5c5f4d26c4a275122026a41deac8334d9b40858a91fa7679397afcff22218a843790e91a94d4decb04fe66123eb9792037589694c006cc4a26d62c43a1028b2ae004ac887f89f65ab542487e25fd570d4d462e21c926693772da7513668d6d6553e02153faff1c08e0fd0bcb6deeaa01d2e074425bb26fd2afd6f039e7c45a6d7ae4726aba3287bfc71bd6b821edd5e320c6a8bd2dd3c9dc8b980426d0bd79e08b65044882a5e109c1f1fb1b187bb05ea352f55a18ca9fb7de64d21d7ff97d9345ed2eda3afa6a6303c373042eef282fcd76d36077206bdaea49416e5785003124f89fc3c405022b12a43f0d22209e4f9e76c532b8cdcec1cbcf0649c713794ce5cabe73648a221da7a75f73b636878c756b9161ad44daca245cdb319eafc3f095a7417ff7b2669ce6e1841741c867d4139af438f1a0f05477cb0c50f2b6349ee017b477f5b396ce9417ee38979cb4a18c4cabd1d48bb11f5b099bcbde7cf55b6c41955d19530dedd8793ddcfa4ae6dd0c08c13adbbf2cf7bed0e580ab18d1136f593a92b6eff4680174efafbe4622ad138e34b83ee7c4f6439e762832cbc1bf1b3746c7a577c2dc8f7adff2d11416467eb07ec8a6fa634b37f288219916896520532b2ca2445cb883c51dcc9ca36e788ba964f1f4770f2abbe992c17af1752ddba705ecd786659ccd21091686ec6a0a64b849f05cf1975c812adba852280b73eff0f6daec29326121de13d5436b21e882a0488135011414ae97d6af2fbbbce89dd757c13a093b2b30aea09a66a5f4f18d7343a2ba4fa98795b953196b5520fa6038bf3c96012f6844cade223b2526cbc17b53fde5bc2c56ae3e1e56e240f0c7312e2092987aad8a7dcae6065a595424bd7ac19bd938e2ccb5ced7a81d55c11137feff9da547c5562f118d4cd8cf50ed55120b7cc2157dde96aca433f49fe98fff74521e4c6f976e04e975557c1f7f1b6760b19b208219f40fd02f05d79248e8b64f24e24ef12e88cbc40dd7f38cbe7f171f91f8ec89ba554e5935d77cb071bac0efff1118a9cde16a47dabab5ce378a4e10d81782613d39a0711a1630cf21e40f6e5d06f5a308558b16ee1dcb490f978b94a8372745968663d707ed0fdf026e50ffba7543f4985505f086e0d9a47baf4cca7ee809dd51f2f287d2a5af1d6fbbf30e8ce1cc6fbc721e100d0991b0ec024c8f7fb8422921498948e53905c3d4507df1ddc9ddeb79a", "h256": "2b5dd5117fa65498f1898d0f815239466f43d3edf6e4b221204dfb4d42174f7e", "h512": "02274dcbd82c1207dd4f50907e381abeef51b86acfc23ff30da90ddb8fdc5b52eaa15f82cd782c0f20e0e5ac4a0154307cd6c89795e2947d113f0ce772ea405e"}]
\ No newline at end of file
+[{"data": "303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132", "h256": "9d151eefd8590b89daa6ba6cb74af9275dd051026bb149a452fd84e5e57b5500", "h512": "1b54d01a4af5b9d5cc3d86d68d285462b19abc2475222f35c085122be4ba1ffa00ad30f8767b3a82384c6574f024c311e2a481332b08ef7f41797891c1646f48"}, {"data": "d1e520e2e5f2f0e82c20d1f2f0e8e1eee6e820e2edf3f6e82c20e2e5fef2fa20f120eceef0ff20f1f2f0e5ebe0ece820ede020f5f0e0e1f0fbff20efebfaeafb20c8e3eef0e5e2fb", "h256": "9dd2fe4e90409e5da87f53976d7405b0c0cac628fc669a741d50063c557e8f50", "h512": "1e88e62226bfca6f9994f1f2d51569e0daf8475a3b0fe61a5300eee46d961376035fe83549ada2b8620fcd7c496ce5b33f0cb9dddc2b6460143b03dabac9fb28"}, {"data": "", "h256": "3f539a213e97c802cc229d474c6aa32a825a360b2a933a949fd925208d9ce1bb", "h512": "8e945da209aa869f0455928529bcae4679e9873ab707b55315f56ceb98bef0a7362f715528356ee83cda5f2aac4c6ad2ba3a715c1bcd81cb8e9f90bf4c1c1a8a"}, {"data": "00", "h256": "6f7305265dc0937440881f9493ef1260f61a9d47742d369e952d41bdb2a9edd1", "h512": "c6b638133ba9706410ddf1bea05d40bf7014500d410c0abde17bff0383c1bd363be2da85c428be86ed48c87fb76013622b22b6aa391d6252ce3a65487b1ba9e4"}, {"data": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "4efe4b89530a0fc90f8c440296ec19ac987b61e8e4e9870d06274a1408237333", "h512": "5bfc84a15cc67a2cd0bbaf7b67e34c239f9cccc89d4798354fdc27ba0a541bb225d2729b5dc56d8ad3720f1c74932978bb50d32a9841bedeb926b682ec97cf97"}, {"data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "df1fda9ce83191390537358031db2ecaa6aa54cd0eda241dc107105e13636b95", "h512": "b0fd29ac1b0df441769ff3fdb8dc564df67721d6ac06fb28ceffb7bbaa7948c6c014ac999235b58cb26fb60fb112a145d7b4ade9ae566bf2611402c552d20db7"}, {"data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "ff494da4e950940619b06db49c4c3dac03a3823e134c22ff0b732599c85b321f", "h512": "a673ba3cb0e06fdbdc2ea86e3600f1deaff1008894c1f248b8a825302d9d4995f4bb73145967aa4d7b3ec0ff5157b91ee57dd4bc77fa29aaa89ccda5be1465b5"}, {"data": "5a", "h256": "2672bee10ba0e1edc145137763f2fce1a932e128f9928fdb4879e6adb13eae05", "h512": "685d1acf4098a513218190577cb022c5738811ff60df9d7008a8cd726ab860d13b3a4fe56b9eeb662a7be2f74d9381531ce9f3b85d7f76f6e449cbea32370b7c"}, {"data": "bc01b0499da8602ea430a380b9ceacec28789b370433183a8a547b1cb08b629692e172b159a647bbed632e698794806e244411db5a8071e7487aa19c50cc561b5cb8cd067a111f6723032aa81114e0ae357012535ad8fe48207db76e2b7775866ead1e20c03f87caa826767426eb5c3a0c2399aec3b36ae5c9c4b4bda02513", "h256": "049233f88f3fdc695c300d8723a7417a71a559866519107dc404550273b9bb25", "h512": "81320b4b2a39341a44b54fb87da7feccea214cb5f196e1a83ba9042035dad0091ba77fec7402daaff63e38b015d31bd4706ec22badac0c13c09d34080107e068"}, {"data": "cc803f7a9137b5ea0bf6e85708772a88e60f1434e069fedf5beb9cbf72d0103822d93c8721b465f887dbe51887693b2cae772c4cd2f41766c9b66bff3b8e8df9b904995ea8296af8bd5b1d70921e8c69db07d9580783884f01c3e165f9ffd55a50025d57258bce36816fd72acf6ef861c03959362bb8750996916df7982c88ad", "h256": "77c2147401b499f521a8fe40b685c31dfdd6b2256cb6f5046d41917d153ac338", "h512": "b0f07211ceab3acf3ecc9ba7368d802ffc7c97b58f332995d39fe3f82f8461762bcf1ade6b80c0d7fabc3f0072df7e5dbcabebc8671c1d1b14dd6e84349e3f56"}, {"data": "58af547930aff8da9b0b43963217982fac8f847f724007f1223af0644d07ce00fa04d7f40a4b49fef3651a6f96d3820b924843290e969bb3adfe17012374c8c49d828865a0868f4f1399c8e0e4535e8b334707a27df6a34bf7c05837abbd6ce474bed5a01a6dabded6b893b24d61965efcfb6136012f38dde2a8d626fe40566ee61658250d0563403b2c576b4c370fb75c679f1dc242d58f60a2841588ca07e63a09c3f584cae0296101b6e07035e5a96f869dec345c09bc69b80871bd33e04b03a1fed988846e325f393333b7993c02a9e85bffa4892e8c850e220d710e23a30c28337208c4d21d2977c6e702519720f037c81c31da51888ced5f3e371a8b8d8604e620a4b067d23471f7f9a18d4686dc83387f0a3f5a3393c9a19303887ffbf765188a5259e4a9564bf890510fbdc43d2bdc8046ead5904deabfed34c601565f7fda8af1555c407a2e47c4ea7c9bef966404c58d2c311d5758eeb853fc0396d48338897b0ae70025f5699953c7f887cd773339a39e7605ffe65e68d4c1d8bc599ba062ce0e29b547c3d1ff2dcdf49848f13ac0f05057b7b9648e545c7853cbb429f9c365e18e6a67fa9316a83d6f15265753d0ac1319601bb7964b8c40d9a085b2d0ee910bf8b692ddb1c251fc9b628b1ad44474c1225e31ed61d49e01913a8af2e17596bc0c37bb91f2c45ecd47cf78772ec28232b414faab372312511edf6609ef0242b258a8122624654afc9c7d857ed1ee1453f418373c474c2f02ec4458b6950fa6fcfec1a20ea2a2b95cb87cd2a7a11b83391aa3e9bffc93c2d1aeb523040132b40b4b97b711d4daabbac5014b3b1f113d7aa6ca371aa312203d41b09725e13ac96cc2a89cc96c35b6bc955545fbc2c16c1a0ecc14937343b1fcaca227956f6fc1252e030dc3eea3e747efcc7855e928466171d13633c3257bce422ef3ed03944f0803ee89616532851098fdb9470217ff3e70d13854a076124bede6225c766bcaec820e8553d77c0a02e8eca9f29142c361650e7766f941001934b33c6c3e63f8d6141597225bd5b06e3ce75a27f00f9b74108b53dddd1356ea95bc5feb7eaffc8b5b04fb4de573e0b509f3d40cc7547ef883a2556c4228ffdf3e5d0b0723b02d3e9af4f2f485e66f5d6c64df27aed2b9a9aaabc52b2fad6f7524fac9d01559f09f8c75fd83f1cd1f72897bd91f6a466e0a1042f7d0a497cbcedec181c5da7ed59cef7373caec08cb7a839ca22d4e3980a9cd91f9826fe2da10721d4862445e7f11e94bec11707e8d927bbb523e1fb97846d72598420155fe9ef98e86231263eb8a3ae8449a687b4766bdbea5f16e95bc010e88cee042dd919cb88f0ec00aa91ff41f9a93e639f6a4979f697225ac19decba47ffa774c078b70aa3f1e3487db480a2a82", "h256": "ef3532fda5f4d85901285f3e9957b30ad157718985b27218294f79b7d121e1a3", "h512": "0d697609f4ad12a5dcfbd6d0c23c43754d5ae176a2dcf114f5e5ec940352927d63fb3c9512d5dd3d1f4da82a4ebfe2468afa19ec4489674b68288905d4f02292"}, {"data": "8ebe2f03649da0063143af3dd9392943d9c739bf0d4dd0b035b17c8b27a2ba095d994869d834823c420ab004350e748fe6330b8ce0c65fbca197749d06eee0454ca593f6b8094aadb000779d307298ff91386fac86ca349a94ee957b9c4282985cfea9178c50874eb49c3574336368df6e95d8d867f841941eafb831fcf42712731d7a30ef48dc6bb9d56ee4936cb0ad5bc6db58a9560e753094e13705f2f79c14e55811049ddfc14910d967aec3ab4fdcb11c55e10947df0d9679d26ec06a76856a0bdca1a922ff282324715e0d7bf773b945ff8bb9ab0eddbf51a974060a578077902e9791ab3dd43748720d2b9990f1810d0ae0855fbb4a273e3b007181224a283b7c2082b70b8d4ce1cfe0bab2437e95f5e849cd6231bab4d139411dc0b7b561098114ae732d8348b45d573f31621c5a8a69bbec14de8e29379593039c88661d86f569680ffdfbaa6bcab29509659ad08f328479d14387cce4152ca3a2460ecf0a1ae30cbe36c6f28dcc724c98a0db6ddbef9d95340448e7e9fd375c5aa1c8056e2bb169c5f23f0251215e8d115dd1f92c9039a52b861e872b70f58f4fb8776e3707517be65475a123199ad467dbfbcdbe0aea51b287ab2db9788587069db3915d1931a43810b9141a66bc3194bcc152d8819657f903d95b8cf3af0778fff6942e213727442def6a62ae5ad15ba0805aea914d36879a29f30d62f67a036d6463502878ce5e8ac199ffc5fdbf9df3ce35bdc6e3ef90da7c21b1113b989380b13f9dcb1c051cc5ef0bb2c85a68e06a5ce41ae0af29310e5e23eab173b5ec7deab80019faf953e3a506b0a1a92ab9889016371438f1baf215171d00009389ac1381eacc686f8dddf9776fd21b31cc7d8fc9971a2ac7bb2c2e7ced26ce279fc7256ab2f39d1c85882bc3a5bd114555e36e0032b307ce7426da0e01dbf33d748cd669ffb85f371b12d45a7637fd7b485a3c98f2af4f1a8bbce1138764306324e1a096f142170a01cc50bd9c217788a9e6116476743bcdc721ef8a561c175e15b89dee2a2db0ca822e2f6d118051a24ff9e26f378f8be1ad4d00399def2d6a1266dbb9ac56efbdf3fcfc5ce7eae2a6a9b83958b7325ac0baffd0d67ee0923861e14c80fd5552f6d4f12c8bc5e24b5716a8edc072c5c16e8a8a6d9c7e15b3144b0707fb67ed249901b0c9a92983305685f6a7e086bd0fe8c976bb8dd79404ef0f4b2f4aedbb618ac9da33d5bbca752da0dea67ce9424fd65a37ae2c89a6564a4492413cb40b59b7c65a3bc802a3334e38f69ec13a99c491ad5c00b173faaef574e1d4335077afa2225290ff1679175d4e992a581758374634b3f37d7dfa3b54851c6a1732f849d644331f6225ad8bb95eacfade5932f3a5f0224749dcd511c1c6309408f4a54f367958178d759e1b95822a187e8ecaf1b5fdf3619c369d32589f4955b8aff860ea9ae50080cd8a404bd3236a19ea53daba2159976e36ad4ccdb290bd56f98dda59a4f70683dcc50c3d2b7966e410991ea02f30ed5071cd23baf7d542e864d3b9121a13b0a1217e11d4332350530a6846f29978d1527f190a4cb25fd26cca4a2e1a70e1be3893c0336bb6647fb8476fda28ef40bbc8bdb809a8d5548c9c2e4bda5718d43d2854ed9121e3cfc3e23618f6710afee7d5c5f4d26c4a275122026a41deac8334d9b40858a91fa7679397afcff22218a843790e91a94d4decb04fe66123eb9792037589694c006cc4a26d62c43a1028b2ae004ac887f89f65ab542487e25fd570d4d462e21c926693772da7513668d6d6553e02153faff1c08e0fd0bcb6deeaa01d2e074425bb26fd2afd6f039e7c45a6d7ae4726aba3287bfc71bd6b821edd5e320c6a8bd2dd3c9dc8b980426d0bd79e08b65044882a5e109c1f1fb1b187bb05ea352f55a18ca9fb7de64d21d7ff97d9345ed2eda3afa6a6303c373042eef282fcd76d36077206bdaea49416e5785003124f89fc3c405022b12a43f0d22209e4f9e76c532b8cdcec1cbcf0649c713794ce5cabe73648a221da7a75f73b636878c756b9161ad44daca245cdb319eafc3f095a7417ff7b2669ce6e1841741c867d4139af438f1a0f05477cb0c50f2b6349ee017b477f5b396ce9417ee38979cb4a18c4cabd1d48bb11f5b099bcbde7cf55b6c41955d19530dedd8793ddcfa4ae6dd0c08c13adbbf2cf7bed0e580ab18d1136f593a92b6eff4680174efafbe4622ad138e34b83ee7c4f6439e762832cbc1bf1b3746c7a577c2dc8f7adff2d11416467eb07ec8a6fa634b37f288219916896520532b2ca2445cb883c51dcc9ca36e788ba964f1f4770f2abbe992c17af1752ddba705ecd786659ccd21091686ec6a0a64b849f05cf1975c812adba852280b73eff0f6daec29326121de13d5436b21e882a0488135011414ae97d6af2fbbbce89dd757c13a093b2b30aea09a66a5f4f18d7343a2ba4fa98795b953196b5520fa6038bf3c96012f6844cade223b2526cbc17b53fde5bc2c56ae3e1e56e240f0c7312e2092987aad8a7dcae6065a595424bd7ac19bd938e2ccb5ced7a81d55c11137feff9da547c5562f118d4cd8cf50ed55120b7cc2157dde96aca433f49fe98fff74521e4c6f976e04e975557c1f7f1b6760b19b208219f40fd02f05d79248e8b64f24e24ef12e88cbc40dd7f38cbe7f171f91f8ec89ba554e5935d77cb071bac0efff1118a9cde16a47dabab5ce378a4e10d81782613d39a0711a1630cf21e40f6e5d06f5a308558b16ee1dcb490f978b94a8372745968663d707ed0fdf026e50ffba7543f4985505f086e0d9a47baf4cca7ee809dd51f2f287d2a5af1d6fbbf30e8ce1cc6fbc721e100d0991b0ec024c8f7fb8422921498948e53905c3d4507df1ddc9ddeb79a", "h256": "2b5dd5117fa65498f1898d0f815239466f43d3edf6e4b221204dfb4d42174f7e", "h512": "02274dcbd82c1207dd4f50907e381abeef51b86acfc23ff30da90ddb8fdc5b52eaa15f82cd782c0f20e0e5ac4a0154307cd6c89795e2947d113f0ce772ea405e"}]
From c0368fd1315ace2620c59fe8c648a0a26f93e2bd Mon Sep 17 00:00:00 2001
From: debug23win <215046748+debug23win@users.noreply.github.com>
Date: Fri, 2 Oct 2026 01:38:01 +0300
Subject: [PATCH 2/3] Keep original file formatting and tag the exact release
commit
---
.github/workflows/build.yml | 1 +
.gitignore | 1 -
LICENSE | 1 -
SignService.sln | 1 -
src/SignService/App.axaml | 1 -
src/SignService/App.axaml.cs | 1 -
src/SignService/Program.cs | 1 -
src/SignService/Services/CadesAttributes.cs | 1 -
src/SignService/Services/CertificateProvider.cs | 1 -
src/SignService/Services/CertificateVault.cs | 1 -
src/SignService/Services/NativeSign.cs | 1 -
src/SignService/Services/PdfStamper.cs | 1 -
src/SignService/Services/SavedCertificateInfo.cs | 1 -
src/SignService/Services/Streebog.cs | 1 -
src/SignService/Services/UpdateService.cs | 1 -
src/SignService/ViewModels/CertificateItem.cs | 1 -
src/SignService/ViewModels/SignFileItem.cs | 1 -
src/SignService/Views/AboutDialog.axaml | 1 -
src/SignService/Views/AboutDialog.axaml.cs | 1 -
src/SignService/Views/ConfirmDialog.axaml | 1 -
src/SignService/Views/ConfirmDialog.axaml.cs | 1 -
src/SignService/Views/PasswordDialog.axaml | 1 -
src/SignService/Views/PasswordDialog.axaml.cs | 1 -
src/SignService/app.manifest | 1 -
tests/SignService.Tests/SignService.Tests.csproj | 1 -
tests/SignService.Tests/streebog_vectors.json | 2 +-
26 files changed, 2 insertions(+), 25 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0317fdf..7a8a626 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -64,5 +64,6 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.release_tag || github.ref_name }}
+ target_commitish: ${{ github.sha }}
files: publish/SignService.exe
body_path: CHANGELOG.md
diff --git a/.gitignore b/.gitignore
index 680f71c..6f5d3e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,3 @@ obj/
.idea/
artifacts/
publish/
-
diff --git a/LICENSE b/LICENSE
index b9869fd..2c154ee 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-
diff --git a/SignService.sln b/SignService.sln
index 58c27d9..df07912 100644
--- a/SignService.sln
+++ b/SignService.sln
@@ -30,4 +30,3 @@ Global
{8890B6DC-E903-4D7E-AF79-9215F9BC39B0} = {1D19B574-1DCC-45AE-991D-32715C73EA9B}
EndGlobalSection
EndGlobal
-
diff --git a/src/SignService/App.axaml b/src/SignService/App.axaml
index 98e3540..8d771b0 100644
--- a/src/SignService/App.axaml
+++ b/src/SignService/App.axaml
@@ -7,4 +7,3 @@
-
diff --git a/src/SignService/App.axaml.cs b/src/SignService/App.axaml.cs
index b8bbcc2..1057ae5 100644
--- a/src/SignService/App.axaml.cs
+++ b/src/SignService/App.axaml.cs
@@ -34,4 +34,3 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}
}
-
diff --git a/src/SignService/Program.cs b/src/SignService/Program.cs
index 02e4f9b..aaad3a9 100644
--- a/src/SignService/Program.cs
+++ b/src/SignService/Program.cs
@@ -18,4 +18,3 @@ public static AppBuilder BuildAvaloniaApp()
.WithInterFont()
.LogToTrace();
}
-
diff --git a/src/SignService/Services/CadesAttributes.cs b/src/SignService/Services/CadesAttributes.cs
index e6a755f..5a49439 100644
--- a/src/SignService/Services/CadesAttributes.cs
+++ b/src/SignService/Services/CadesAttributes.cs
@@ -79,4 +79,3 @@ private static (string? HashOid, byte[] Hash) HashCertificate(X509Certificate2 c
_ => (null, SHA256.HashData(certificate.RawData)),
};
}
-
diff --git a/src/SignService/Services/CertificateProvider.cs b/src/SignService/Services/CertificateProvider.cs
index 5a8702e..aa546a7 100644
--- a/src/SignService/Services/CertificateProvider.cs
+++ b/src/SignService/Services/CertificateProvider.cs
@@ -49,4 +49,3 @@ public static string GetIssuerName(X509Certificate2 certificate)
return string.IsNullOrWhiteSpace(cn) ? certificate.Issuer : cn;
}
}
-
diff --git a/src/SignService/Services/CertificateVault.cs b/src/SignService/Services/CertificateVault.cs
index ab2d779..8029c4b 100644
--- a/src/SignService/Services/CertificateVault.cs
+++ b/src/SignService/Services/CertificateVault.cs
@@ -206,4 +206,3 @@ public void Delete(SavedCertificateInfo info, AppSettings settings)
settings.Save();
}
}
-
diff --git a/src/SignService/Services/NativeSign.cs b/src/SignService/Services/NativeSign.cs
index 76676bf..299904b 100644
--- a/src/SignService/Services/NativeSign.cs
+++ b/src/SignService/Services/NativeSign.cs
@@ -202,4 +202,3 @@ private static Exception NewSignError()
return new InvalidOperationException($"CryptSignMessage: {inner.Message} (0x{error:X8}).{hint}", inner);
}
}
-
diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs
index 4251b5d..983326f 100644
--- a/src/SignService/Services/PdfStamper.cs
+++ b/src/SignService/Services/PdfStamper.cs
@@ -213,4 +213,3 @@ private sealed class EmbeddedFontResolver : IFontResolver
new(bold ? "stamp#b" : "stamp#r");
}
}
-
diff --git a/src/SignService/Services/SavedCertificateInfo.cs b/src/SignService/Services/SavedCertificateInfo.cs
index bf55f79..7e5c3bf 100644
--- a/src/SignService/Services/SavedCertificateInfo.cs
+++ b/src/SignService/Services/SavedCertificateInfo.cs
@@ -28,4 +28,3 @@ public class SavedCertificateInfo
///
public string CertificateBase64 { get; set; } = "";
}
-
diff --git a/src/SignService/Services/Streebog.cs b/src/SignService/Services/Streebog.cs
index ed1dbae..850f68e 100644
--- a/src/SignService/Services/Streebog.cs
+++ b/src/SignService/Services/Streebog.cs
@@ -722,4 +722,3 @@ private static byte[] Lps(byte[] a)
},
};
}
-
diff --git a/src/SignService/Services/UpdateService.cs b/src/SignService/Services/UpdateService.cs
index bcdb830..dee39f4 100644
--- a/src/SignService/Services/UpdateService.cs
+++ b/src/SignService/Services/UpdateService.cs
@@ -148,4 +148,3 @@ @echo off
});
}
}
-
diff --git a/src/SignService/ViewModels/CertificateItem.cs b/src/SignService/ViewModels/CertificateItem.cs
index ed22bda..55c21b5 100644
--- a/src/SignService/ViewModels/CertificateItem.cs
+++ b/src/SignService/ViewModels/CertificateItem.cs
@@ -58,4 +58,3 @@ public CertificateItem(SavedCertificateInfo saved)
+ (IsExpired ? " (истёк)" : string.Empty)
+ (IsSaved ? " — сохранён на ПК 🔒" : string.Empty);
}
-
diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs
index 8715b34..896428d 100644
--- a/src/SignService/ViewModels/SignFileItem.cs
+++ b/src/SignService/ViewModels/SignFileItem.cs
@@ -104,4 +104,3 @@ public int AttachSignatures(IEnumerable sigPaths)
_ => $"{bytes / (1024.0 * 1024 * 1024):0.##} ГБ",
};
}
-
diff --git a/src/SignService/Views/AboutDialog.axaml b/src/SignService/Views/AboutDialog.axaml
index bc0346b..c799543 100644
--- a/src/SignService/Views/AboutDialog.axaml
+++ b/src/SignService/Views/AboutDialog.axaml
@@ -54,4 +54,3 @@
Margin="0,12,0,0" MinWidth="100" Name="CloseButton"/>
-
diff --git a/src/SignService/Views/AboutDialog.axaml.cs b/src/SignService/Views/AboutDialog.axaml.cs
index 4d1047f..74f0fbc 100644
--- a/src/SignService/Views/AboutDialog.axaml.cs
+++ b/src/SignService/Views/AboutDialog.axaml.cs
@@ -124,4 +124,3 @@ private static void OpenUrl(string url)
}
}
}
-
diff --git a/src/SignService/Views/ConfirmDialog.axaml b/src/SignService/Views/ConfirmDialog.axaml
index cf502c4..8cf1c33 100644
--- a/src/SignService/Views/ConfirmDialog.axaml
+++ b/src/SignService/Views/ConfirmDialog.axaml
@@ -14,4 +14,3 @@
-
diff --git a/src/SignService/Views/ConfirmDialog.axaml.cs b/src/SignService/Views/ConfirmDialog.axaml.cs
index f5d76e9..3fc0adb 100644
--- a/src/SignService/Views/ConfirmDialog.axaml.cs
+++ b/src/SignService/Views/ConfirmDialog.axaml.cs
@@ -19,4 +19,3 @@ public ConfirmDialog(string title, string message)
NoButton.Click += (_, _) => Close(false);
}
}
-
diff --git a/src/SignService/Views/PasswordDialog.axaml b/src/SignService/Views/PasswordDialog.axaml
index a10458d..a6be6a8 100644
--- a/src/SignService/Views/PasswordDialog.axaml
+++ b/src/SignService/Views/PasswordDialog.axaml
@@ -28,4 +28,3 @@
-
diff --git a/src/SignService/Views/PasswordDialog.axaml.cs b/src/SignService/Views/PasswordDialog.axaml.cs
index d3a2494..1129ff1 100644
--- a/src/SignService/Views/PasswordDialog.axaml.cs
+++ b/src/SignService/Views/PasswordDialog.axaml.cs
@@ -60,4 +60,3 @@ private void ShowError(string text)
ErrorText.IsVisible = true;
}
}
-
diff --git a/src/SignService/app.manifest b/src/SignService/app.manifest
index 9db67c0..2b83fbd 100644
--- a/src/SignService/app.manifest
+++ b/src/SignService/app.manifest
@@ -9,4 +9,3 @@
-
diff --git a/tests/SignService.Tests/SignService.Tests.csproj b/tests/SignService.Tests/SignService.Tests.csproj
index 6a9dbad..2294755 100644
--- a/tests/SignService.Tests/SignService.Tests.csproj
+++ b/tests/SignService.Tests/SignService.Tests.csproj
@@ -17,4 +17,3 @@
-
diff --git a/tests/SignService.Tests/streebog_vectors.json b/tests/SignService.Tests/streebog_vectors.json
index 150dae3..9ba0f03 100644
--- a/tests/SignService.Tests/streebog_vectors.json
+++ b/tests/SignService.Tests/streebog_vectors.json
@@ -1 +1 @@
-[{"data": "303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132", "h256": "9d151eefd8590b89daa6ba6cb74af9275dd051026bb149a452fd84e5e57b5500", "h512": "1b54d01a4af5b9d5cc3d86d68d285462b19abc2475222f35c085122be4ba1ffa00ad30f8767b3a82384c6574f024c311e2a481332b08ef7f41797891c1646f48"}, {"data": "d1e520e2e5f2f0e82c20d1f2f0e8e1eee6e820e2edf3f6e82c20e2e5fef2fa20f120eceef0ff20f1f2f0e5ebe0ece820ede020f5f0e0e1f0fbff20efebfaeafb20c8e3eef0e5e2fb", "h256": "9dd2fe4e90409e5da87f53976d7405b0c0cac628fc669a741d50063c557e8f50", "h512": "1e88e62226bfca6f9994f1f2d51569e0daf8475a3b0fe61a5300eee46d961376035fe83549ada2b8620fcd7c496ce5b33f0cb9dddc2b6460143b03dabac9fb28"}, {"data": "", "h256": "3f539a213e97c802cc229d474c6aa32a825a360b2a933a949fd925208d9ce1bb", "h512": "8e945da209aa869f0455928529bcae4679e9873ab707b55315f56ceb98bef0a7362f715528356ee83cda5f2aac4c6ad2ba3a715c1bcd81cb8e9f90bf4c1c1a8a"}, {"data": "00", "h256": "6f7305265dc0937440881f9493ef1260f61a9d47742d369e952d41bdb2a9edd1", "h512": "c6b638133ba9706410ddf1bea05d40bf7014500d410c0abde17bff0383c1bd363be2da85c428be86ed48c87fb76013622b22b6aa391d6252ce3a65487b1ba9e4"}, {"data": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "4efe4b89530a0fc90f8c440296ec19ac987b61e8e4e9870d06274a1408237333", "h512": "5bfc84a15cc67a2cd0bbaf7b67e34c239f9cccc89d4798354fdc27ba0a541bb225d2729b5dc56d8ad3720f1c74932978bb50d32a9841bedeb926b682ec97cf97"}, {"data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "df1fda9ce83191390537358031db2ecaa6aa54cd0eda241dc107105e13636b95", "h512": "b0fd29ac1b0df441769ff3fdb8dc564df67721d6ac06fb28ceffb7bbaa7948c6c014ac999235b58cb26fb60fb112a145d7b4ade9ae566bf2611402c552d20db7"}, {"data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "ff494da4e950940619b06db49c4c3dac03a3823e134c22ff0b732599c85b321f", "h512": "a673ba3cb0e06fdbdc2ea86e3600f1deaff1008894c1f248b8a825302d9d4995f4bb73145967aa4d7b3ec0ff5157b91ee57dd4bc77fa29aaa89ccda5be1465b5"}, {"data": "5a", "h256": "2672bee10ba0e1edc145137763f2fce1a932e128f9928fdb4879e6adb13eae05", "h512": "685d1acf4098a513218190577cb022c5738811ff60df9d7008a8cd726ab860d13b3a4fe56b9eeb662a7be2f74d9381531ce9f3b85d7f76f6e449cbea32370b7c"}, {"data": "bc01b0499da8602ea430a380b9ceacec28789b370433183a8a547b1cb08b629692e172b159a647bbed632e698794806e244411db5a8071e7487aa19c50cc561b5cb8cd067a111f6723032aa81114e0ae357012535ad8fe48207db76e2b7775866ead1e20c03f87caa826767426eb5c3a0c2399aec3b36ae5c9c4b4bda02513", "h256": "049233f88f3fdc695c300d8723a7417a71a559866519107dc404550273b9bb25", "h512": "81320b4b2a39341a44b54fb87da7feccea214cb5f196e1a83ba9042035dad0091ba77fec7402daaff63e38b015d31bd4706ec22badac0c13c09d34080107e068"}, {"data": "cc803f7a9137b5ea0bf6e85708772a88e60f1434e069fedf5beb9cbf72d0103822d93c8721b465f887dbe51887693b2cae772c4cd2f41766c9b66bff3b8e8df9b904995ea8296af8bd5b1d70921e8c69db07d9580783884f01c3e165f9ffd55a50025d57258bce36816fd72acf6ef861c03959362bb8750996916df7982c88ad", "h256": "77c2147401b499f521a8fe40b685c31dfdd6b2256cb6f5046d41917d153ac338", "h512": "b0f07211ceab3acf3ecc9ba7368d802ffc7c97b58f332995d39fe3f82f8461762bcf1ade6b80c0d7fabc3f0072df7e5dbcabebc8671c1d1b14dd6e84349e3f56"}, {"data": "58af547930aff8da9b0b43963217982fac8f847f724007f1223af0644d07ce00fa04d7f40a4b49fef3651a6f96d3820b924843290e969bb3adfe17012374c8c49d828865a0868f4f1399c8e0e4535e8b334707a27df6a34bf7c05837abbd6ce474bed5a01a6dabded6b893b24d61965efcfb6136012f38dde2a8d626fe40566ee61658250d0563403b2c576b4c370fb75c679f1dc242d58f60a2841588ca07e63a09c3f584cae0296101b6e07035e5a96f869dec345c09bc69b80871bd33e04b03a1fed988846e325f393333b7993c02a9e85bffa4892e8c850e220d710e23a30c28337208c4d21d2977c6e702519720f037c81c31da51888ced5f3e371a8b8d8604e620a4b067d23471f7f9a18d4686dc83387f0a3f5a3393c9a19303887ffbf765188a5259e4a9564bf890510fbdc43d2bdc8046ead5904deabfed34c601565f7fda8af1555c407a2e47c4ea7c9bef966404c58d2c311d5758eeb853fc0396d48338897b0ae70025f5699953c7f887cd773339a39e7605ffe65e68d4c1d8bc599ba062ce0e29b547c3d1ff2dcdf49848f13ac0f05057b7b9648e545c7853cbb429f9c365e18e6a67fa9316a83d6f15265753d0ac1319601bb7964b8c40d9a085b2d0ee910bf8b692ddb1c251fc9b628b1ad44474c1225e31ed61d49e01913a8af2e17596bc0c37bb91f2c45ecd47cf78772ec28232b414faab372312511edf6609ef0242b258a8122624654afc9c7d857ed1ee1453f418373c474c2f02ec4458b6950fa6fcfec1a20ea2a2b95cb87cd2a7a11b83391aa3e9bffc93c2d1aeb523040132b40b4b97b711d4daabbac5014b3b1f113d7aa6ca371aa312203d41b09725e13ac96cc2a89cc96c35b6bc955545fbc2c16c1a0ecc14937343b1fcaca227956f6fc1252e030dc3eea3e747efcc7855e928466171d13633c3257bce422ef3ed03944f0803ee89616532851098fdb9470217ff3e70d13854a076124bede6225c766bcaec820e8553d77c0a02e8eca9f29142c361650e7766f941001934b33c6c3e63f8d6141597225bd5b06e3ce75a27f00f9b74108b53dddd1356ea95bc5feb7eaffc8b5b04fb4de573e0b509f3d40cc7547ef883a2556c4228ffdf3e5d0b0723b02d3e9af4f2f485e66f5d6c64df27aed2b9a9aaabc52b2fad6f7524fac9d01559f09f8c75fd83f1cd1f72897bd91f6a466e0a1042f7d0a497cbcedec181c5da7ed59cef7373caec08cb7a839ca22d4e3980a9cd91f9826fe2da10721d4862445e7f11e94bec11707e8d927bbb523e1fb97846d72598420155fe9ef98e86231263eb8a3ae8449a687b4766bdbea5f16e95bc010e88cee042dd919cb88f0ec00aa91ff41f9a93e639f6a4979f697225ac19decba47ffa774c078b70aa3f1e3487db480a2a82", "h256": "ef3532fda5f4d85901285f3e9957b30ad157718985b27218294f79b7d121e1a3", "h512": "0d697609f4ad12a5dcfbd6d0c23c43754d5ae176a2dcf114f5e5ec940352927d63fb3c9512d5dd3d1f4da82a4ebfe2468afa19ec4489674b68288905d4f02292"}, {"data": "8ebe2f03649da0063143af3dd9392943d9c739bf0d4dd0b035b17c8b27a2ba095d994869d834823c420ab004350e748fe6330b8ce0c65fbca197749d06eee0454ca593f6b8094aadb000779d307298ff91386fac86ca349a94ee957b9c4282985cfea9178c50874eb49c3574336368df6e95d8d867f841941eafb831fcf42712731d7a30ef48dc6bb9d56ee4936cb0ad5bc6db58a9560e753094e13705f2f79c14e55811049ddfc14910d967aec3ab4fdcb11c55e10947df0d9679d26ec06a76856a0bdca1a922ff282324715e0d7bf773b945ff8bb9ab0eddbf51a974060a578077902e9791ab3dd43748720d2b9990f1810d0ae0855fbb4a273e3b007181224a283b7c2082b70b8d4ce1cfe0bab2437e95f5e849cd6231bab4d139411dc0b7b561098114ae732d8348b45d573f31621c5a8a69bbec14de8e29379593039c88661d86f569680ffdfbaa6bcab29509659ad08f328479d14387cce4152ca3a2460ecf0a1ae30cbe36c6f28dcc724c98a0db6ddbef9d95340448e7e9fd375c5aa1c8056e2bb169c5f23f0251215e8d115dd1f92c9039a52b861e872b70f58f4fb8776e3707517be65475a123199ad467dbfbcdbe0aea51b287ab2db9788587069db3915d1931a43810b9141a66bc3194bcc152d8819657f903d95b8cf3af0778fff6942e213727442def6a62ae5ad15ba0805aea914d36879a29f30d62f67a036d6463502878ce5e8ac199ffc5fdbf9df3ce35bdc6e3ef90da7c21b1113b989380b13f9dcb1c051cc5ef0bb2c85a68e06a5ce41ae0af29310e5e23eab173b5ec7deab80019faf953e3a506b0a1a92ab9889016371438f1baf215171d00009389ac1381eacc686f8dddf9776fd21b31cc7d8fc9971a2ac7bb2c2e7ced26ce279fc7256ab2f39d1c85882bc3a5bd114555e36e0032b307ce7426da0e01dbf33d748cd669ffb85f371b12d45a7637fd7b485a3c98f2af4f1a8bbce1138764306324e1a096f142170a01cc50bd9c217788a9e6116476743bcdc721ef8a561c175e15b89dee2a2db0ca822e2f6d118051a24ff9e26f378f8be1ad4d00399def2d6a1266dbb9ac56efbdf3fcfc5ce7eae2a6a9b83958b7325ac0baffd0d67ee0923861e14c80fd5552f6d4f12c8bc5e24b5716a8edc072c5c16e8a8a6d9c7e15b3144b0707fb67ed249901b0c9a92983305685f6a7e086bd0fe8c976bb8dd79404ef0f4b2f4aedbb618ac9da33d5bbca752da0dea67ce9424fd65a37ae2c89a6564a4492413cb40b59b7c65a3bc802a3334e38f69ec13a99c491ad5c00b173faaef574e1d4335077afa2225290ff1679175d4e992a581758374634b3f37d7dfa3b54851c6a1732f849d644331f6225ad8bb95eacfade5932f3a5f0224749dcd511c1c6309408f4a54f367958178d759e1b95822a187e8ecaf1b5fdf3619c369d32589f4955b8aff860ea9ae50080cd8a404bd3236a19ea53daba2159976e36ad4ccdb290bd56f98dda59a4f70683dcc50c3d2b7966e410991ea02f30ed5071cd23baf7d542e864d3b9121a13b0a1217e11d4332350530a6846f29978d1527f190a4cb25fd26cca4a2e1a70e1be3893c0336bb6647fb8476fda28ef40bbc8bdb809a8d5548c9c2e4bda5718d43d2854ed9121e3cfc3e23618f6710afee7d5c5f4d26c4a275122026a41deac8334d9b40858a91fa7679397afcff22218a843790e91a94d4decb04fe66123eb9792037589694c006cc4a26d62c43a1028b2ae004ac887f89f65ab542487e25fd570d4d462e21c926693772da7513668d6d6553e02153faff1c08e0fd0bcb6deeaa01d2e074425bb26fd2afd6f039e7c45a6d7ae4726aba3287bfc71bd6b821edd5e320c6a8bd2dd3c9dc8b980426d0bd79e08b65044882a5e109c1f1fb1b187bb05ea352f55a18ca9fb7de64d21d7ff97d9345ed2eda3afa6a6303c373042eef282fcd76d36077206bdaea49416e5785003124f89fc3c405022b12a43f0d22209e4f9e76c532b8cdcec1cbcf0649c713794ce5cabe73648a221da7a75f73b636878c756b9161ad44daca245cdb319eafc3f095a7417ff7b2669ce6e1841741c867d4139af438f1a0f05477cb0c50f2b6349ee017b477f5b396ce9417ee38979cb4a18c4cabd1d48bb11f5b099bcbde7cf55b6c41955d19530dedd8793ddcfa4ae6dd0c08c13adbbf2cf7bed0e580ab18d1136f593a92b6eff4680174efafbe4622ad138e34b83ee7c4f6439e762832cbc1bf1b3746c7a577c2dc8f7adff2d11416467eb07ec8a6fa634b37f288219916896520532b2ca2445cb883c51dcc9ca36e788ba964f1f4770f2abbe992c17af1752ddba705ecd786659ccd21091686ec6a0a64b849f05cf1975c812adba852280b73eff0f6daec29326121de13d5436b21e882a0488135011414ae97d6af2fbbbce89dd757c13a093b2b30aea09a66a5f4f18d7343a2ba4fa98795b953196b5520fa6038bf3c96012f6844cade223b2526cbc17b53fde5bc2c56ae3e1e56e240f0c7312e2092987aad8a7dcae6065a595424bd7ac19bd938e2ccb5ced7a81d55c11137feff9da547c5562f118d4cd8cf50ed55120b7cc2157dde96aca433f49fe98fff74521e4c6f976e04e975557c1f7f1b6760b19b208219f40fd02f05d79248e8b64f24e24ef12e88cbc40dd7f38cbe7f171f91f8ec89ba554e5935d77cb071bac0efff1118a9cde16a47dabab5ce378a4e10d81782613d39a0711a1630cf21e40f6e5d06f5a308558b16ee1dcb490f978b94a8372745968663d707ed0fdf026e50ffba7543f4985505f086e0d9a47baf4cca7ee809dd51f2f287d2a5af1d6fbbf30e8ce1cc6fbc721e100d0991b0ec024c8f7fb8422921498948e53905c3d4507df1ddc9ddeb79a", "h256": "2b5dd5117fa65498f1898d0f815239466f43d3edf6e4b221204dfb4d42174f7e", "h512": "02274dcbd82c1207dd4f50907e381abeef51b86acfc23ff30da90ddb8fdc5b52eaa15f82cd782c0f20e0e5ac4a0154307cd6c89795e2947d113f0ce772ea405e"}]
+[{"data": "303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132", "h256": "9d151eefd8590b89daa6ba6cb74af9275dd051026bb149a452fd84e5e57b5500", "h512": "1b54d01a4af5b9d5cc3d86d68d285462b19abc2475222f35c085122be4ba1ffa00ad30f8767b3a82384c6574f024c311e2a481332b08ef7f41797891c1646f48"}, {"data": "d1e520e2e5f2f0e82c20d1f2f0e8e1eee6e820e2edf3f6e82c20e2e5fef2fa20f120eceef0ff20f1f2f0e5ebe0ece820ede020f5f0e0e1f0fbff20efebfaeafb20c8e3eef0e5e2fb", "h256": "9dd2fe4e90409e5da87f53976d7405b0c0cac628fc669a741d50063c557e8f50", "h512": "1e88e62226bfca6f9994f1f2d51569e0daf8475a3b0fe61a5300eee46d961376035fe83549ada2b8620fcd7c496ce5b33f0cb9dddc2b6460143b03dabac9fb28"}, {"data": "", "h256": "3f539a213e97c802cc229d474c6aa32a825a360b2a933a949fd925208d9ce1bb", "h512": "8e945da209aa869f0455928529bcae4679e9873ab707b55315f56ceb98bef0a7362f715528356ee83cda5f2aac4c6ad2ba3a715c1bcd81cb8e9f90bf4c1c1a8a"}, {"data": "00", "h256": "6f7305265dc0937440881f9493ef1260f61a9d47742d369e952d41bdb2a9edd1", "h512": "c6b638133ba9706410ddf1bea05d40bf7014500d410c0abde17bff0383c1bd363be2da85c428be86ed48c87fb76013622b22b6aa391d6252ce3a65487b1ba9e4"}, {"data": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "4efe4b89530a0fc90f8c440296ec19ac987b61e8e4e9870d06274a1408237333", "h512": "5bfc84a15cc67a2cd0bbaf7b67e34c239f9cccc89d4798354fdc27ba0a541bb225d2729b5dc56d8ad3720f1c74932978bb50d32a9841bedeb926b682ec97cf97"}, {"data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "df1fda9ce83191390537358031db2ecaa6aa54cd0eda241dc107105e13636b95", "h512": "b0fd29ac1b0df441769ff3fdb8dc564df67721d6ac06fb28ceffb7bbaa7948c6c014ac999235b58cb26fb60fb112a145d7b4ade9ae566bf2611402c552d20db7"}, {"data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "h256": "ff494da4e950940619b06db49c4c3dac03a3823e134c22ff0b732599c85b321f", "h512": "a673ba3cb0e06fdbdc2ea86e3600f1deaff1008894c1f248b8a825302d9d4995f4bb73145967aa4d7b3ec0ff5157b91ee57dd4bc77fa29aaa89ccda5be1465b5"}, {"data": "5a", "h256": "2672bee10ba0e1edc145137763f2fce1a932e128f9928fdb4879e6adb13eae05", "h512": "685d1acf4098a513218190577cb022c5738811ff60df9d7008a8cd726ab860d13b3a4fe56b9eeb662a7be2f74d9381531ce9f3b85d7f76f6e449cbea32370b7c"}, {"data": "bc01b0499da8602ea430a380b9ceacec28789b370433183a8a547b1cb08b629692e172b159a647bbed632e698794806e244411db5a8071e7487aa19c50cc561b5cb8cd067a111f6723032aa81114e0ae357012535ad8fe48207db76e2b7775866ead1e20c03f87caa826767426eb5c3a0c2399aec3b36ae5c9c4b4bda02513", "h256": "049233f88f3fdc695c300d8723a7417a71a559866519107dc404550273b9bb25", "h512": "81320b4b2a39341a44b54fb87da7feccea214cb5f196e1a83ba9042035dad0091ba77fec7402daaff63e38b015d31bd4706ec22badac0c13c09d34080107e068"}, {"data": "cc803f7a9137b5ea0bf6e85708772a88e60f1434e069fedf5beb9cbf72d0103822d93c8721b465f887dbe51887693b2cae772c4cd2f41766c9b66bff3b8e8df9b904995ea8296af8bd5b1d70921e8c69db07d9580783884f01c3e165f9ffd55a50025d57258bce36816fd72acf6ef861c03959362bb8750996916df7982c88ad", "h256": "77c2147401b499f521a8fe40b685c31dfdd6b2256cb6f5046d41917d153ac338", "h512": "b0f07211ceab3acf3ecc9ba7368d802ffc7c97b58f332995d39fe3f82f8461762bcf1ade6b80c0d7fabc3f0072df7e5dbcabebc8671c1d1b14dd6e84349e3f56"}, {"data": "58af547930aff8da9b0b43963217982fac8f847f724007f1223af0644d07ce00fa04d7f40a4b49fef3651a6f96d3820b924843290e969bb3adfe17012374c8c49d828865a0868f4f1399c8e0e4535e8b334707a27df6a34bf7c05837abbd6ce474bed5a01a6dabded6b893b24d61965efcfb6136012f38dde2a8d626fe40566ee61658250d0563403b2c576b4c370fb75c679f1dc242d58f60a2841588ca07e63a09c3f584cae0296101b6e07035e5a96f869dec345c09bc69b80871bd33e04b03a1fed988846e325f393333b7993c02a9e85bffa4892e8c850e220d710e23a30c28337208c4d21d2977c6e702519720f037c81c31da51888ced5f3e371a8b8d8604e620a4b067d23471f7f9a18d4686dc83387f0a3f5a3393c9a19303887ffbf765188a5259e4a9564bf890510fbdc43d2bdc8046ead5904deabfed34c601565f7fda8af1555c407a2e47c4ea7c9bef966404c58d2c311d5758eeb853fc0396d48338897b0ae70025f5699953c7f887cd773339a39e7605ffe65e68d4c1d8bc599ba062ce0e29b547c3d1ff2dcdf49848f13ac0f05057b7b9648e545c7853cbb429f9c365e18e6a67fa9316a83d6f15265753d0ac1319601bb7964b8c40d9a085b2d0ee910bf8b692ddb1c251fc9b628b1ad44474c1225e31ed61d49e01913a8af2e17596bc0c37bb91f2c45ecd47cf78772ec28232b414faab372312511edf6609ef0242b258a8122624654afc9c7d857ed1ee1453f418373c474c2f02ec4458b6950fa6fcfec1a20ea2a2b95cb87cd2a7a11b83391aa3e9bffc93c2d1aeb523040132b40b4b97b711d4daabbac5014b3b1f113d7aa6ca371aa312203d41b09725e13ac96cc2a89cc96c35b6bc955545fbc2c16c1a0ecc14937343b1fcaca227956f6fc1252e030dc3eea3e747efcc7855e928466171d13633c3257bce422ef3ed03944f0803ee89616532851098fdb9470217ff3e70d13854a076124bede6225c766bcaec820e8553d77c0a02e8eca9f29142c361650e7766f941001934b33c6c3e63f8d6141597225bd5b06e3ce75a27f00f9b74108b53dddd1356ea95bc5feb7eaffc8b5b04fb4de573e0b509f3d40cc7547ef883a2556c4228ffdf3e5d0b0723b02d3e9af4f2f485e66f5d6c64df27aed2b9a9aaabc52b2fad6f7524fac9d01559f09f8c75fd83f1cd1f72897bd91f6a466e0a1042f7d0a497cbcedec181c5da7ed59cef7373caec08cb7a839ca22d4e3980a9cd91f9826fe2da10721d4862445e7f11e94bec11707e8d927bbb523e1fb97846d72598420155fe9ef98e86231263eb8a3ae8449a687b4766bdbea5f16e95bc010e88cee042dd919cb88f0ec00aa91ff41f9a93e639f6a4979f697225ac19decba47ffa774c078b70aa3f1e3487db480a2a82", "h256": "ef3532fda5f4d85901285f3e9957b30ad157718985b27218294f79b7d121e1a3", "h512": "0d697609f4ad12a5dcfbd6d0c23c43754d5ae176a2dcf114f5e5ec940352927d63fb3c9512d5dd3d1f4da82a4ebfe2468afa19ec4489674b68288905d4f02292"}, {"data": "8ebe2f03649da0063143af3dd9392943d9c739bf0d4dd0b035b17c8b27a2ba095d994869d834823c420ab004350e748fe6330b8ce0c65fbca197749d06eee0454ca593f6b8094aadb000779d307298ff91386fac86ca349a94ee957b9c4282985cfea9178c50874eb49c3574336368df6e95d8d867f841941eafb831fcf42712731d7a30ef48dc6bb9d56ee4936cb0ad5bc6db58a9560e753094e13705f2f79c14e55811049ddfc14910d967aec3ab4fdcb11c55e10947df0d9679d26ec06a76856a0bdca1a922ff282324715e0d7bf773b945ff8bb9ab0eddbf51a974060a578077902e9791ab3dd43748720d2b9990f1810d0ae0855fbb4a273e3b007181224a283b7c2082b70b8d4ce1cfe0bab2437e95f5e849cd6231bab4d139411dc0b7b561098114ae732d8348b45d573f31621c5a8a69bbec14de8e29379593039c88661d86f569680ffdfbaa6bcab29509659ad08f328479d14387cce4152ca3a2460ecf0a1ae30cbe36c6f28dcc724c98a0db6ddbef9d95340448e7e9fd375c5aa1c8056e2bb169c5f23f0251215e8d115dd1f92c9039a52b861e872b70f58f4fb8776e3707517be65475a123199ad467dbfbcdbe0aea51b287ab2db9788587069db3915d1931a43810b9141a66bc3194bcc152d8819657f903d95b8cf3af0778fff6942e213727442def6a62ae5ad15ba0805aea914d36879a29f30d62f67a036d6463502878ce5e8ac199ffc5fdbf9df3ce35bdc6e3ef90da7c21b1113b989380b13f9dcb1c051cc5ef0bb2c85a68e06a5ce41ae0af29310e5e23eab173b5ec7deab80019faf953e3a506b0a1a92ab9889016371438f1baf215171d00009389ac1381eacc686f8dddf9776fd21b31cc7d8fc9971a2ac7bb2c2e7ced26ce279fc7256ab2f39d1c85882bc3a5bd114555e36e0032b307ce7426da0e01dbf33d748cd669ffb85f371b12d45a7637fd7b485a3c98f2af4f1a8bbce1138764306324e1a096f142170a01cc50bd9c217788a9e6116476743bcdc721ef8a561c175e15b89dee2a2db0ca822e2f6d118051a24ff9e26f378f8be1ad4d00399def2d6a1266dbb9ac56efbdf3fcfc5ce7eae2a6a9b83958b7325ac0baffd0d67ee0923861e14c80fd5552f6d4f12c8bc5e24b5716a8edc072c5c16e8a8a6d9c7e15b3144b0707fb67ed249901b0c9a92983305685f6a7e086bd0fe8c976bb8dd79404ef0f4b2f4aedbb618ac9da33d5bbca752da0dea67ce9424fd65a37ae2c89a6564a4492413cb40b59b7c65a3bc802a3334e38f69ec13a99c491ad5c00b173faaef574e1d4335077afa2225290ff1679175d4e992a581758374634b3f37d7dfa3b54851c6a1732f849d644331f6225ad8bb95eacfade5932f3a5f0224749dcd511c1c6309408f4a54f367958178d759e1b95822a187e8ecaf1b5fdf3619c369d32589f4955b8aff860ea9ae50080cd8a404bd3236a19ea53daba2159976e36ad4ccdb290bd56f98dda59a4f70683dcc50c3d2b7966e410991ea02f30ed5071cd23baf7d542e864d3b9121a13b0a1217e11d4332350530a6846f29978d1527f190a4cb25fd26cca4a2e1a70e1be3893c0336bb6647fb8476fda28ef40bbc8bdb809a8d5548c9c2e4bda5718d43d2854ed9121e3cfc3e23618f6710afee7d5c5f4d26c4a275122026a41deac8334d9b40858a91fa7679397afcff22218a843790e91a94d4decb04fe66123eb9792037589694c006cc4a26d62c43a1028b2ae004ac887f89f65ab542487e25fd570d4d462e21c926693772da7513668d6d6553e02153faff1c08e0fd0bcb6deeaa01d2e074425bb26fd2afd6f039e7c45a6d7ae4726aba3287bfc71bd6b821edd5e320c6a8bd2dd3c9dc8b980426d0bd79e08b65044882a5e109c1f1fb1b187bb05ea352f55a18ca9fb7de64d21d7ff97d9345ed2eda3afa6a6303c373042eef282fcd76d36077206bdaea49416e5785003124f89fc3c405022b12a43f0d22209e4f9e76c532b8cdcec1cbcf0649c713794ce5cabe73648a221da7a75f73b636878c756b9161ad44daca245cdb319eafc3f095a7417ff7b2669ce6e1841741c867d4139af438f1a0f05477cb0c50f2b6349ee017b477f5b396ce9417ee38979cb4a18c4cabd1d48bb11f5b099bcbde7cf55b6c41955d19530dedd8793ddcfa4ae6dd0c08c13adbbf2cf7bed0e580ab18d1136f593a92b6eff4680174efafbe4622ad138e34b83ee7c4f6439e762832cbc1bf1b3746c7a577c2dc8f7adff2d11416467eb07ec8a6fa634b37f288219916896520532b2ca2445cb883c51dcc9ca36e788ba964f1f4770f2abbe992c17af1752ddba705ecd786659ccd21091686ec6a0a64b849f05cf1975c812adba852280b73eff0f6daec29326121de13d5436b21e882a0488135011414ae97d6af2fbbbce89dd757c13a093b2b30aea09a66a5f4f18d7343a2ba4fa98795b953196b5520fa6038bf3c96012f6844cade223b2526cbc17b53fde5bc2c56ae3e1e56e240f0c7312e2092987aad8a7dcae6065a595424bd7ac19bd938e2ccb5ced7a81d55c11137feff9da547c5562f118d4cd8cf50ed55120b7cc2157dde96aca433f49fe98fff74521e4c6f976e04e975557c1f7f1b6760b19b208219f40fd02f05d79248e8b64f24e24ef12e88cbc40dd7f38cbe7f171f91f8ec89ba554e5935d77cb071bac0efff1118a9cde16a47dabab5ce378a4e10d81782613d39a0711a1630cf21e40f6e5d06f5a308558b16ee1dcb490f978b94a8372745968663d707ed0fdf026e50ffba7543f4985505f086e0d9a47baf4cca7ee809dd51f2f287d2a5af1d6fbbf30e8ce1cc6fbc721e100d0991b0ec024c8f7fb8422921498948e53905c3d4507df1ddc9ddeb79a", "h256": "2b5dd5117fa65498f1898d0f815239466f43d3edf6e4b221204dfb4d42174f7e", "h512": "02274dcbd82c1207dd4f50907e381abeef51b86acfc23ff30da90ddb8fdc5b52eaa15f82cd782c0f20e0e5ac4a0154307cd6c89795e2947d113f0ce772ea405e"}]
\ No newline at end of file
From 45de8c674f87906d4c6148b9c2665fb7ac1a6162 Mon Sep 17 00:00:00 2001
From: debug23win <215046748+debug23win@users.noreply.github.com>
Date: Fri, 2 Oct 2026 01:45:24 +0300
Subject: [PATCH 3/3] Prepare release 1.1.0
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e9f97b..23f715d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# История версий SignService
-## v1.1.0 — не выпущена
+## v1.1.0 — 02.10.2026
- **Проверка ЭЦП без КриптоПро**: пункт «Инструменты → Проверить ЭЦП…»,
CMS/CAdES с ГОСТ-2001, ГОСТ-2012 256/512, RSA и ECDSA; отчёт по каждому