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