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="Операции с подписями без подписания: извлечение, объединение, сборка контейнера, штамп"> + await BrowseStampOnlyAsync(vm); vm.BuildContainerRequested += async (_, _) => await BrowseBuildContainerAsync(vm); vm.AddPoaRequested += async (_, _) => await BrowsePoaAsync(vm); + vm.VerifySignatureRequested += async (_, _) => await new VerificationDialog(vm.Settings).ShowDialog(this); vm.PropertyChanged += (_, args) => { // автопрокрутка лога вниз diff --git a/src/SignService/Views/VerificationDialog.axaml b/src/SignService/Views/VerificationDialog.axaml new file mode 100644 index 0000000..4be4693 --- /dev/null +++ b/src/SignService/Views/VerificationDialog.axaml @@ -0,0 +1,40 @@ + + + + + +