diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8f660c4..7a8a626 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
@@ -60,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/CHANGELOG.md b/CHANGELOG.md
index 18a2b91..23f715d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,23 @@
# История версий SignService
-## v1.1.0 — не выпущена
+## v1.1.0 — 02.10.2026
+
+- **Проверка ЭЦП без КриптоПро**: пункт «Инструменты → Проверить ЭЦП…»,
+ CMS/CAdES с ГОСТ-2001, ГОСТ-2012 256/512, RSA и ECDSA; отчёт по каждому
+ подписанту с экспортом в TXT и отменой проверки.
+- Раздельные результаты для документа, значения подписи, привязки сертификата,
+ доверия, отзыва и TSA. Цепочки сертификатов проверяются без криптопровайдера;
+ вложенный корень автоматически не считается доверенным. Поддерживаются
+ подписанные CRL/OCSP и сетевые запросы по выбору; отсутствие подтверждений
+ отображается явно. TSA проверяется вместе с привязкой к подписи и запросу.
+- Исправлены проверки объединения и МЧД: совпадение хеша больше не пропускает
+ повреждённое значение подписи; исключённый подписант не оставляет старый
+ вложенный документ, выбранный режим откреплённой подписи сохраняется.
+- МЧД проверяется по текущим XML/SIG и копируется из проверенного снимка;
+ конфликт имён файлов и противоречивые ИНН/СНИЛС останавливают операцию.
+ Файлы подписи сохраняются через временный файл и атомарную замену.
+- Ограничена вложенность ASN.1 и исправлен контроль переполнения длины.
+ Тесты запускаются для pull request; сборка Windows зависит от их успеха.
- **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается
ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно
@@ -8,8 +25,8 @@
(по блоку данных на каждого). Прежний режим «подписывать копию со
штампом» остался отдельной галочкой с предупреждением.
- **Доверенность МЧД** (кнопка «Добавить доверенность…», как в Контуре):
- загрузка XML (EMCHD_1) и подписи руководителя, проверка подписи по хешу
- (для ГОСТ — Стрибогом), срока действия и соответствия представителя
+ загрузка XML (EMCHD_1) и подписи руководителя, криптографическая проверка,
+ проверка срока действия и соответствия представителя
сертификату по ИНН/СНИЛС; при подписании файлы МЧД копируются рядом
с документом, номер МЧД добавляется в штамп на PDF.
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/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/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/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/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/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/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/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/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/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="Операции с подписями без подписания: извлечение, объединение, сборка контейнера, штамп">
+