From 85ae1352fa3694472d1e30dd422a53b204281763 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:13:49 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20UX:=20=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BB=D0=BE=D0=B3,=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=88=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=BF=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=AD=D0=A6=D0=9F,=20=D0=9F=D0=9A=D0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - лог операций всегда виден, дублирующая статусная строка убрана (все сообщения идут в лог с отметкой времени); - диалог параметров штампа перед подписанием и операцией «Штамп»: страницы — последняя/первая/все/указанные («1,3-5», как в печати), дата, логотип, «подписывать копию со штампом»; на каждой выбранной странице — отдельная плашка на КАЖДОГО подписанта, колонками из правого нижнего угла (PdfStamper.StampOptions/ResolvePages); - окно «Подписанты и проверка ЭЦП» (SignatureVerifier): по каждому подписанту — сертификат, срок, время подписания, штамп времени, соответствие документу (messageDigest, для ГОСТ — Стрибог) и криптопроверка значения подписи; вызов из ПКМ, из «Инструментов» (для внешних .sig) и для прикреплённых контейнеров без документа; - штамп времени (CAdES-T) и адрес TSA вынесены в окно «Настройки…»; - контекстное меню файлов списка: подписанты/проверка, приложить подписи, штамп, извлечь, собрать контейнер, открыть папку, убрать; - «Штамп на PDF» без подписания берёт подписантов из «имя.sig» рядом; - инструмент «Проверить подпись…» в меню. Тесты: разбор страниц (диапазоны, ошибки), рендер 3 подписанта × все страницы, проверка ЭЦП (2 валидных, протухший помечается, прикреплённый контейнер без документа, формат отчёта). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BtHSRXSuM8qTBTUBe29624 --- CHANGELOG.md | 13 ++ src/SignService/Services/AppSettings.cs | 6 + src/SignService/Services/CmsMerger.cs | 18 ++ src/SignService/Services/DocumentSigner.cs | 18 +- src/SignService/Services/PdfStamper.cs | 176 +++++++++++---- src/SignService/Services/SignatureVerifier.cs | 181 ++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 204 ++++++++++++++++-- src/SignService/Views/MainWindow.axaml | 102 ++++----- src/SignService/Views/MainWindow.axaml.cs | 45 +++- src/SignService/Views/SettingsDialog.axaml | 28 +++ src/SignService/Views/SettingsDialog.axaml.cs | 20 ++ src/SignService/Views/SignersDialog.axaml | 19 ++ src/SignService/Views/SignersDialog.axaml.cs | 19 ++ .../Views/StampOptionsDialog.axaml | 44 ++++ .../Views/StampOptionsDialog.axaml.cs | 96 +++++++++ tests/SignService.Tests/Program.cs | 50 +++++ 16 files changed, 920 insertions(+), 119 deletions(-) create mode 100644 src/SignService/Services/SignatureVerifier.cs create mode 100644 src/SignService/Views/SettingsDialog.axaml create mode 100644 src/SignService/Views/SettingsDialog.axaml.cs create mode 100644 src/SignService/Views/SignersDialog.axaml create mode 100644 src/SignService/Views/SignersDialog.axaml.cs create mode 100644 src/SignService/Views/StampOptionsDialog.axaml create mode 100644 src/SignService/Views/StampOptionsDialog.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a2b91..62149f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## v1.1.0 — не выпущена +- **Переработан интерфейс**: лог операций всегда виден (статусная строка + убрана — всё пишется в лог); настройки штампа времени (CAdES-T, адрес TSA) + вынесены в окно «Настройки…»; у файлов появилось контекстное меню (ПКМ): + подписанты и проверка ЭЦП, приложение подписей, штамп, извлечение, + сборка контейнера, открытие папки. +- **Просмотр подписантов и проверка ЭЦП**: окно с отчётом по каждому + подписанту — сертификат, срок, время подписания, штамп времени, + соответствие документу (для ГОСТ — Стрибогом на любой платформе) + и криптопроверка подписи. +- **Диалог параметров штампа** перед операцией: страницы (первая/последняя/ + все/указанные «1,3-5»), дата, логотип, режим подписи копии; на каждой + выбранной странице — ОТДЕЛЬНАЯ плашка на каждого подписанта + (колонками из правого нижнего угла). - **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно без подписи — после подписания и со всеми подписантами итоговой подписи diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs index 9e54390..4471703 100644 --- a/src/SignService/Services/AppSettings.cs +++ b/src/SignService/Services/AppSettings.cs @@ -45,6 +45,12 @@ public class AppSettings /// public bool StampSignCopy { get; set; } + /// Режим страниц штампа (Last/First/All/Custom). + public string StampPagesMode { get; set; } = "Last"; + + /// Номера страниц для режима Custom («1,3-5»). + public string? StampCustomPages { get; set; } + /// Путь к логотипу организации для штампа (PNG/JPEG). public string? StampLogoPath { get; set; } diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs index eaffbaa..7ce272d 100644 --- a/src/SignService/Services/CmsMerger.cs +++ b/src/SignService/Services/CmsMerger.cs @@ -500,6 +500,24 @@ public enum DocMatch Unknown, } + /// + /// Сверка каждого подписанта с документом по его messageDigest + /// (для ГОСТ — Стрибогом, без криптопровайдера); порядок — как в SignerInfos. + /// + public static IReadOnlyList CheckSignersAgainstDocument(byte[] signature, byte[] document) + { + var parsed = Parse(Normalize(signature)); + var cache = new Dictionary(); + return parsed.Signers + .Select(s => CheckSignerAgainstDocument(s.Der, document, cache) switch + { + SignerDocMatch.Match => DocMatch.Match, + SignerDocMatch.Mismatch => DocMatch.Mismatch, + _ => DocMatch.Unknown, + }) + .ToList(); + } + /// /// Сверяет подпись с документом по messageDigest всех подписантов /// (для ГОСТ — Стрибогом, без криптопровайдера). Mismatch — хотя бы один diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs index 365315d..f0d16e4 100644 --- a/src/SignService/Services/DocumentSigner.cs +++ b/src/SignService/Services/DocumentSigner.cs @@ -55,6 +55,12 @@ public sealed record SignOptions /// Путь к картинке логотипа для штампа (PNG/JPEG) или null. public string? StampLogoPath { get; init; } + /// + /// Полные параметры штампа (страницы и т.д.); если null — собираются + /// из StampWithDate/StampLogoPath (последняя страница). + /// + public PdfStamper.StampOptions? StampParameters { get; init; } + /// /// Доверенность МЧД: файлы XML и подписи руководителя копируются в каталог /// подписанного документа (как это делает Контур), номер попадает в PDF-штамп. @@ -118,13 +124,18 @@ public async Task SignFileAsync( // (без подписи, со всеми подписантами) создаётся ниже, после подписания. var targetPath = filePath; var stampAsCopy = options.Stamp && !options.StampSignCopy && PdfStamper.IsPdf(filePath); + var stampOptions = options.StampParameters + ?? new PdfStamper.StampOptions { WithDate = options.StampWithDate, LogoPath = options.StampLogoPath }; + stampOptions = stampOptions with + { + PoaNumber = stampOptions.PoaNumber ?? options.PowerOfAttorney?.Number, + }; if (options.Stamp && options.StampSignCopy && PdfStamper.IsPdf(filePath)) { try { targetPath = PdfStamper.CreateStampedCopy( - filePath, certificate, options.StampWithDate, options.StampLogoPath, DateTime.Now, - options.PowerOfAttorney?.Number); + filePath, new[] { certificate }, stampOptions, DateTime.Now); } catch (Exception e) { @@ -197,8 +208,7 @@ public async Task SignFileAsync( ? signerCerts : new List { certificate }; stampedCopyPath = PdfStamper.CreateStampedCopy( - filePath, stampCerts, options.StampWithDate, options.StampLogoPath, DateTime.Now, - options.PowerOfAttorney?.Number); + filePath, stampCerts, stampOptions, DateTime.Now); foreach (var c in signerCerts) c.Dispose(); } diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs index 983326f..cf46e99 100644 --- a/src/SignService/Services/PdfStamper.cs +++ b/src/SignService/Services/PdfStamper.cs @@ -28,10 +28,31 @@ static PdfStamper() public static bool IsPdf(string filePath) => Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase); - /// - /// Создаёт штампованную копию PDF. Возвращает путь к копии - /// («имя (со штампом).pdf» рядом с исходным файлом; существующая копия обновляется). - /// + /// На каких страницах ставить штампы. + public enum StampPages + { + Last, + First, + All, + Custom, + } + + /// Параметры визуального штампа. + public sealed record StampOptions + { + public bool WithDate { get; init; } = true; + + public string? LogoPath { get; init; } + + public string? PoaNumber { get; init; } + + public StampPages Pages { get; init; } = StampPages.Last; + + /// Номера страниц для режима Custom, например «1,3-5». + public string? CustomPages { get; init; } + } + + /// Совместимая обёртка: один подписант, штамп на последней странице. public static string CreateStampedCopy( string pdfPath, X509Certificate2 certificate, @@ -39,13 +60,10 @@ public static string CreateStampedCopy( string? logoPath, DateTime signTime, string? poaNumber = null) - => CreateStampedCopy(pdfPath, new[] { certificate }, withDate, logoPath, signTime, poaNumber); + => CreateStampedCopy(pdfPath, new[] { certificate }, + new StampOptions { WithDate = withDate, LogoPath = logoPath, PoaNumber = poaNumber }, signTime); - /// - /// Штампованная копия с несколькими подписантами (по блоку данных на каждого) — - /// используется в режиме «копия отдельно», когда штамп отражает всех - /// подписантов итоговой подписи. - /// + /// Совместимая обёртка: несколько подписантов, последняя страница. public static string CreateStampedCopy( string pdfPath, IReadOnlyList certificates, @@ -53,6 +71,19 @@ public static string CreateStampedCopy( string? logoPath, DateTime signTime, string? poaNumber = null) + => CreateStampedCopy(pdfPath, certificates, + new StampOptions { WithDate = withDate, LogoPath = logoPath, PoaNumber = poaNumber }, signTime); + + /// + /// Создаёт штампованную копию «имя (со штампом).pdf» (существующая обновляется): + /// на выбранных страницах — ОТДЕЛЬНАЯ плашка на каждого подписанта + /// (колонками из правого нижнего угла). + /// + public static string CreateStampedCopy( + string pdfPath, + IReadOnlyList certificates, + StampOptions options, + DateTime signTime) { if (certificates.Count == 0) throw new ArgumentException("Нет сертификатов для штампа.", nameof(certificates)); @@ -62,53 +93,122 @@ public static string CreateStampedCopy( var outputPath = Path.Combine(directory, $"{stem} (со штампом).pdf"); using var document = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Modify); - var page = document.Pages[document.Pages.Count - 1]; - using (var gfx = XGraphics.FromPdfPage(page)) + foreach (var pageIndex in ResolvePages(options, document.Pages.Count)) { - DrawStamp(gfx, page, certificates, withDate, logoPath, signTime, poaNumber); + var page = document.Pages[pageIndex]; + using var gfx = XGraphics.FromPdfPage(page); + DrawStampsOnPage(gfx, page, certificates, options, signTime); } document.Save(outputPath); return outputPath; } - private static void DrawStamp( + /// Индексы страниц (0-based) по выбранному режиму; «1,3-5» — как в диалогах печати. + public static IReadOnlyList ResolvePages(StampOptions options, int pageCount) + { + switch (options.Pages) + { + case StampPages.First: + return new[] { 0 }; + case StampPages.All: + return Enumerable.Range(0, pageCount).ToArray(); + case StampPages.Custom: + var result = new SortedSet(); + foreach (var part in (options.CustomPages ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var range = part.Split('-', 2, StringSplitOptions.TrimEntries); + if (!int.TryParse(range[0], out var from)) + throw new InvalidOperationException($"Непонятный номер страницы «{part}» — укажите, например: 1,3-5."); + var to = from; + if (range.Length == 2 && !int.TryParse(range[1], out to)) + throw new InvalidOperationException($"Непонятный диапазон страниц «{part}»."); + for (var p = Math.Max(1, from); p <= Math.Min(pageCount, to); p++) + result.Add(p - 1); + } + + if (result.Count == 0) + throw new InvalidOperationException( + $"В документе {pageCount} стр., а указанные страницы «{options.CustomPages}» не найдены."); + return result.ToArray(); + default: + return new[] { pageCount - 1 }; + } + } + + // Раскладка: плашки подписантов колонками из правого нижнего угла вверх, + // при нехватке высоты — следующая колонка левее. + private static void DrawStampsOnPage( XGraphics gfx, PdfPage page, IReadOnlyList certificates, - bool withDate, string? logoPath, DateTime signTime, string? poaNumber) + StampOptions options, DateTime signTime) { const double width = 250; const double margin = 20; + const double gap = 8; + + var x = page.Width.Point - width - margin; + var y = page.Height.Point - margin; + + foreach (var certificate in certificates) + { + var height = ComputeStampHeight(certificate, options); + if (y - height < margin && y < page.Height.Point - margin) + { + // колонка заполнена — следующая левее + x -= width + gap; + y = page.Height.Point - margin; + } + + y -= height; + DrawSingleStamp(gfx, new XRect(x, y, width, height), certificate, options, signTime); + y -= gap; + } + } + + private static List StampLines(X509Certificate2 certificate, StampOptions options) + { + var lines = new List + { + $"Сертификат: {certificate.SerialNumber}", + $"Владелец: {CertificateProvider.GetSubjectName(certificate)}", + $"Действителен: с {certificate.NotBefore:dd.MM.yyyy} по {certificate.NotAfter:dd.MM.yyyy}", + }; + if (!string.IsNullOrWhiteSpace(options.PoaNumber)) + lines.Add($"Действует на основании МЧД № {options.PoaNumber}"); + return lines; + } + + private const double StampPad = 8; + private const double StampLineHeight = 10.5; + private const double StampTitleHeight = 24; + + private static double ComputeStampHeight(X509Certificate2 certificate, StampOptions options) + { + var logoSize = options.LogoPath is not null ? 34.0 : 0.0; + var bodyLines = StampLines(certificate, options).Count + (options.WithDate ? 1 : 0); + return StampPad * 2 + Math.Max(StampTitleHeight, logoSize) + 4 + bodyLines * StampLineHeight; + } + + private static void DrawSingleStamp( + XGraphics gfx, XRect rect, X509Certificate2 certificate, StampOptions options, DateTime signTime) + { + var width = rect.Width; var blue = XColor.FromArgb(0x1B, 0x4F, 0x9C); var pen = new XPen(blue, 1.2); var titleFont = new XFont("stamp", 8, XFontStyleEx.Bold); var textFont = new XFont("stamp", 7, XFontStyleEx.Regular); - // Содержимое: блок на каждого подписанта, затем общие строки - var lines = new List(); - for (var i = 0; i < certificates.Count; i++) - { - var c = certificates[i]; - if (certificates.Count > 1) - lines.Add($"Подписант {i + 1}:"); - lines.Add($"Сертификат: {c.SerialNumber}"); - lines.Add($"Владелец: {CertificateProvider.GetSubjectName(c)}"); - lines.Add($"Действителен: с {c.NotBefore:dd.MM.yyyy} по {c.NotAfter:dd.MM.yyyy}"); - } - - if (!string.IsNullOrWhiteSpace(poaNumber)) - lines.Add($"Действует на основании МЧД № {poaNumber}"); - var dateLine = withDate ? $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}" : null; + var lines = StampLines(certificate, options); + var dateLine = options.WithDate ? $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}" : null; + var logoPath = options.LogoPath; - const double pad = 8; - const double lineHeight = 10.5; - const double titleHeight = 24; + const double pad = StampPad; + const double lineHeight = StampLineHeight; + const double titleHeight = StampTitleHeight; var logoSize = logoPath is not null ? 34.0 : 0.0; - var bodyLines = lines.Count + (dateLine is null ? 0 : 1); - var height = pad * 2 + Math.Max(titleHeight, logoSize) + 4 + bodyLines * lineHeight; - var x = page.Width.Point - width - margin; - var y = page.Height.Point - height - margin; - var rect = new XRect(x, y, width, height); + var x = rect.X; + var y = rect.Y; // Фон и рамка со скруглением gfx.DrawRoundedRectangle(pen, XBrushes.White, rect, new XSize(8, 8)); diff --git a/src/SignService/Services/SignatureVerifier.cs b/src/SignService/Services/SignatureVerifier.cs new file mode 100644 index 0000000..d3cbc90 --- /dev/null +++ b/src/SignService/Services/SignatureVerifier.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace SignService.Services; + +/// +/// Просмотр подписантов и проверка ЭЦП: по каждому подписанту — данные +/// сертификата, время подписания, штамп времени, соответствие документу +/// (по messageDigest; для ГОСТ — Стрибогом, на любой платформе) и +/// криптографическая проверка значения подписи (для ГОСТ — на Windows +/// с установленным криптопровайдером). +/// +public static class SignatureVerifier +{ + public sealed record SignerReport( + string Subject, + string Issuer, + string Serial, + DateTime? NotBefore, + DateTime? NotAfter, + DateTime? SigningTime, + bool HasTimestamp, + string DocMatchText, + string CryptoText, + bool Ok); + + public sealed record Report( + bool Attached, + IReadOnlyList Signers, + string Summary); + + /// + /// Проверяет подпись. document — байты документа для откреплённой подписи + /// (для прикреплённой не нужен: содержимое берётся из контейнера). + /// + public static Report Verify(byte[] signature, byte[]? document) + { + var normalized = CmsMerger.Normalize(signature); + var info = CmsMerger.Inspect(normalized); + var attached = info.HasContent; + + // Для прикреплённой подписи документ — внутри контейнера. + var effectiveDocument = attached ? CmsMerger.ExtractContent(normalized) : document; + + // SignedCms для криптопроверки: откреплённой нужен документ. + SignedCms cms; + if (attached) + { + cms = new SignedCms(); + cms.Decode(normalized); + } + else + { + cms = new SignedCms( + new ContentInfo(effectiveDocument ?? Array.Empty()), detached: true); + cms.Decode(normalized); + } + + var docMatches = effectiveDocument is { Length: > 0 } + ? CmsMerger.CheckSignersAgainstDocument(normalized, effectiveDocument) + : null; + + var reports = new List(); + var index = 0; + foreach (SignerInfo signer in cms.SignerInfos) + { + var certificate = signer.Certificate; + + DateTime? signingTime = null; + foreach (CryptographicAttributeObject attr in signer.SignedAttributes) + { + if (attr.Oid.Value == "1.2.840.113549.1.9.5" && attr.Values.Count > 0 + && attr.Values[0] is Pkcs9SigningTime st) + signingTime = st.SigningTime.ToLocalTime(); + } + + var hasTimestamp = signer.UnsignedAttributes.Cast() + .Any(a => a.Oid.Value == TimestampClient.TimeStampTokenOid); + + var match = docMatches is not null && index < docMatches.Count + ? docMatches[index] + : CmsMerger.DocMatch.Unknown; + var docText = match switch + { + CmsMerger.DocMatch.Match => "✓ соответствует документу", + CmsMerger.DocMatch.Mismatch => "✗ НЕ соответствует документу (другой файл или версия)", + _ => effectiveDocument is null + ? "— документ не найден, соответствие не проверялось" + : "— не удалось проверить соответствие", + }; + + string cryptoText; + var cryptoOk = false; + if (match == CmsMerger.DocMatch.Mismatch) + { + cryptoText = "✗ подпись сделана под другим содержимым"; + } + else if (effectiveDocument is null) + { + cryptoText = "— без документа криптопроверка невозможна"; + } + else + { + try + { + signer.CheckSignature(verifySignatureOnly: true); + cryptoText = "✓ подпись математически верна"; + cryptoOk = true; + } + catch (Exception e) when (e is CryptographicException or PlatformNotSupportedException) + { + var gost = (signer.DigestAlgorithm.Value ?? "").StartsWith("1.2.643.", StringComparison.Ordinal); + cryptoText = gost && !HasGostProvider() + ? "— криптопроверка ГОСТ доступна на Windows с КриптоПро CSP" + : "✗ ПОДПИСЬ НЕ ПРОШЛА КРИПТОПРОВЕРКУ: " + e.Message; + } + } + + reports.Add(new SignerReport( + certificate is null ? "(сертификат не вложен)" : CertificateProvider.GetSubjectName(certificate), + certificate is null ? "" : CertificateProvider.GetIssuerName(certificate), + certificate?.SerialNumber ?? "", + certificate?.NotBefore, + certificate?.NotAfter, + signingTime, + hasTimestamp, + docText, + cryptoText, + match == CmsMerger.DocMatch.Match && (cryptoOk || cryptoText.StartsWith("—")))); + index++; + } + + var okCount = reports.Count(r => r.Ok); + var summary = reports.Count == 0 + ? "В подписи нет подписантов." + : okCount == reports.Count + ? $"Все подписанты ({reports.Count}) прошли проверку." + : $"Прошли проверку: {okCount} из {reports.Count} подписантов."; + + return new Report(attached, reports, summary); + } + + /// Текстовый отчёт для окна и лога. + public static string Format(Report report, string documentName, string signatureName) + { + var sb = new StringBuilder(); + sb.AppendLine($"Документ: {documentName}"); + sb.AppendLine($"Подпись: {signatureName} ({(report.Attached ? "прикреплённая — документ внутри" : "откреплённая")})"); + sb.AppendLine($"Подписантов: {report.Signers.Count}"); + sb.AppendLine(); + + var i = 1; + foreach (var s in report.Signers) + { + sb.AppendLine($"Подписант {i++}: {s.Subject}"); + if (s.Issuer.Length > 0) + sb.AppendLine($" Кем выдан: {s.Issuer}"); + if (s.Serial.Length > 0) + sb.AppendLine($" Сертификат № {s.Serial}, действителен {s.NotBefore:dd.MM.yyyy}–{s.NotAfter:dd.MM.yyyy}"); + sb.AppendLine(" Время подписания: " + + (s.SigningTime is { } t ? t.ToString("dd.MM.yyyy HH:mm:ss") : "не указано") + + (s.HasTimestamp ? " (есть штамп времени TSA)" : "")); + sb.AppendLine(" " + s.DocMatchText); + sb.AppendLine(" " + s.CryptoText); + sb.AppendLine(); + } + + sb.AppendLine("Итог: " + report.Summary); + sb.AppendLine(); + sb.AppendLine("Примечание: проверяется соответствие подписи документу и её математическая"); + sb.AppendLine("корректность; доверие к цепочке УЦ и отзыв сертификатов не проверяются."); + return sb.ToString(); + } + + private static bool HasGostProvider() => OperatingSystem.IsWindows(); +} diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs index 0d0ca81..0a60b4c 100644 --- a/src/SignService/ViewModels/MainWindowViewModel.cs +++ b/src/SignService/ViewModels/MainWindowViewModel.cs @@ -159,15 +159,11 @@ private async Task CheckUpdatesOnStartAsync() [ObservableProperty] private string _statusText = "Перетащите файлы в окно или добавьте их через «Обзор…»"; - /// Текст окна лога: все операции с отметкой времени. + /// Текст окна лога (всегда видимого): все операции с отметкой времени. [ObservableProperty] private string _logText = ""; - /// Показывать ли панель лога. - [ObservableProperty] - private bool _isLogVisible; - - // Каждая смена статуса попадает и в лог — плюс подробные строки по файлам. + // Каждая смена статуса попадает в лог — статусная строка в UI не дублируется. partial void OnStatusTextChanged(string value) => Log(value); private void Log(string message) @@ -177,12 +173,18 @@ private void Log(string message) LogText += $"[{DateTime.Now:HH:mm:ss}] {message}\n"; } - [RelayCommand] - private void ToggleLog() => IsLogVisible = !IsLogVisible; - [RelayCommand] private void ClearLog() => LogText = ""; + /// + /// Запрос диалога параметров штампа (реализуется окном). + /// Параметр — показывать ли пункт «подписывать копию со штампом». + /// + public Func>? RequestStampOptionsAsync { get; set; } + + /// Показ отчёта «Подписанты и проверка ЭЦП» (реализуется окном). + public Action? ShowSignersReport { get; set; } + /// /// Запрос диалога выбора файлов; обрабатывается в MainWindow, /// т.к. StorageProvider доступен только на уровне окна. @@ -204,6 +206,12 @@ private void Log(string message) /// Запрос диалога выбора документа для сборки криптоконтейнера. public event EventHandler? BuildContainerRequested; + /// Запрос диалога выбора .sig для проверки ЭЦП. + public event EventHandler? VerifyFileRequested; + + [RelayCommand(CanExecute = nameof(CanBrowse))] + private void VerifyFile() => VerifyFileRequested?.Invoke(this, EventArgs.Empty); + /// /// Запрос пароля у пользователя (заголовок, сообщение, предупреждение или null, /// требуется ли повторный ввод). Возвращает пароль или null при отмене. @@ -702,41 +710,129 @@ public async Task BuildContainerAsync(string documentPath, IReadOnlyList [RelayCommand(CanExecute = nameof(CanStampOnly))] private void StampOnly() => StampOnlyRequested?.Invoke(this, EventArgs.Empty); + /// Начальные параметры диалога штампа — из сохранённых настроек. + public PdfStamper.StampOptions BuildInitialStampOptions() => new() + { + WithDate = StampWithDate, + LogoPath = StampLogoPath, + Pages = Enum.TryParse(_settings.StampPagesMode, out var p) + ? p + : PdfStamper.StampPages.Last, + CustomPages = _settings.StampCustomPages, + }; + + /// Запоминает параметры, выбранные в диалоге штампа. + public void SaveStampOptions(Views.StampOptionsDialog.Result result) + { + StampWithDate = result.Options.WithDate; + StampLogoPath = result.Options.LogoPath; + StampSignCopy = result.SignCopy; + _settings.StampPagesMode = result.Options.Pages.ToString(); + _settings.StampCustomPages = result.Options.CustomPages; + _settings.Save(); + } + /// - /// Ставит визуальный штамп на выбранные PDF без подписания: рядом сохраняется - /// копия «имя (со штампом).pdf». Пароль не нужен — используются только - /// данные сертификата (открытая часть). + /// Ставит визуальный штамп на PDF без подписания. Если рядом с документом + /// есть «имя.sig», плашки ставятся для ВСЕХ его подписантов; иначе — + /// по данным выбранного сертификата. /// - public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths) + public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfStamper.StampOptions options) { if (pdfPaths.Count == 0 || SelectedCertificate is not { } item) return; + options = options with { PoaNumber = options.PoaNumber ?? Poa?.Number }; IsBusy = true; try { - var lines = new List(); foreach (var path in pdfPaths) { + var name = System.IO.Path.GetFileName(path); try { if (!PdfStamper.IsPdf(path)) { - lines.Add($"«{System.IO.Path.GetFileName(path)}»: пропущен (не PDF)"); + Log($"«{name}»: пропущен (не PDF)"); continue; } - var stamped = await Task.Run(() => PdfStamper.CreateStampedCopy( - path, item.Certificate, StampWithDate, StampLogoPath, DateTime.Now, Poa?.Number)); - lines.Add($"«{System.IO.Path.GetFileName(path)}» → «{System.IO.Path.GetFileName(stamped)}»"); + var stamped = await Task.Run(() => + { + var sigPath = path + ".sig"; + var certs = System.IO.File.Exists(sigPath) + ? CmsMerger.GetSignerCertificates(System.IO.File.ReadAllBytes(sigPath)) + : Array.Empty(); + try + { + return PdfStamper.CreateStampedCopy( + path, + certs.Count > 0 ? certs : new[] { item.Certificate }, + options, + DateTime.Now); + } + finally + { + foreach (var c in certs) + c.Dispose(); + } + }); + Log($"Штамп без подписания: «{name}» → «{System.IO.Path.GetFileName(stamped)}»"); } catch (Exception ex) { - lines.Add($"«{System.IO.Path.GetFileName(path)}»: ошибка — {ex.Message}"); + Log($"Штамп без подписания: «{name}» — ошибка: {ex.Message}"); } } + } + finally + { + IsBusy = false; + } + } - StatusText = "Штамп без подписания: " + string.Join(" | ", lines); + /// ПКМ: подписанты и проверка ЭЦП для файла из списка (.sig рядом). + [RelayCommand] + private async Task InspectSignaturesAsync(SignFileItem item) + { + var sigPath = item.FilePath + ".sig"; + if (!System.IO.File.Exists(sigPath)) + { + StatusText = $"Рядом с «{item.FileName}» нет файла подписи «{System.IO.Path.GetFileName(sigPath)}»."; + return; + } + + await VerifySignatureFileAsync(sigPath); + } + + /// Просмотр подписантов и проверка ЭЦП для файла подписи. + public async Task VerifySignatureFileAsync(string sigPath) + { + IsBusy = true; + try + { + var documentPath = sigPath.EndsWith(".sig", StringComparison.OrdinalIgnoreCase) + ? sigPath[..^4] + : null; + byte[]? document = documentPath is not null && System.IO.File.Exists(documentPath) + ? await System.IO.File.ReadAllBytesAsync(documentPath) + : null; + var signature = await System.IO.File.ReadAllBytesAsync(sigPath); + + var report = await Task.Run(() => SignatureVerifier.Verify(signature, document)); + var text = SignatureVerifier.Format( + report, + document is null + ? (report.Attached ? "(внутри контейнера)" : "(не найден)") + : System.IO.Path.GetFileName(documentPath!), + System.IO.Path.GetFileName(sigPath)); + + StatusText = $"Проверка ЭЦП «{System.IO.Path.GetFileName(sigPath)}»: {report.Summary}"; + ShowSignersReport?.Invoke(text); + } + catch (Exception ex) + { + StatusText = "Проверка ЭЦП: " + ex.Message; } finally { @@ -744,6 +840,56 @@ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths) } } + /// ПКМ: штамп для файла из списка (диалог параметров → копия без подписи). + [RelayCommand] + private async Task StampItemAsync(SignFileItem item) + { + if (RequestStampOptionsAsync is null) + return; + var result = await RequestStampOptionsAsync(false); + if (result is null) + return; + SaveStampOptions(result); + await StampWithoutSigningAsync(new[] { item.FilePath }, result.Options); + } + + /// ПКМ: извлечь из контейнера «имя.sig» рядом с файлом. + [RelayCommand] + private async Task ExtractItemAsync(SignFileItem item) + { + var sigPath = item.FilePath + ".sig"; + if (!System.IO.File.Exists(sigPath)) + { + StatusText = $"Рядом с «{item.FileName}» нет файла подписи для извлечения."; + return; + } + + await ExtractContainersAsync(new[] { sigPath }); + } + + /// ПКМ: собрать криптоконтейнер из файла и его подписи рядом. + [RelayCommand] + private Task BuildContainerItemAsync(SignFileItem item) => + BuildContainerAsync(item.FilePath, null); + + /// ПКМ: открыть папку файла. + [RelayCommand] + private void OpenFolder(SignFileItem item) + { + try + { + var directory = System.IO.Path.GetDirectoryName(item.FilePath) ?? "."; + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(directory) + { + UseShellExecute = true, + }); + } + catch (Exception ex) + { + StatusText = "Не удалось открыть папку: " + ex.Message; + } + } + /// Объединяет выбранные файлы подписей в один — без создания своей подписи. public async Task MergeSignatureFilesAsync(IReadOnlyList paths) { @@ -852,6 +998,23 @@ private async Task SignAllAsync() return; } + // Параметры штампа спрашиваем один раз на весь пакет. + PdfStamper.StampOptions? stampParameters = null; + if (UseStamp) + { + if (RequestStampOptionsAsync is null) + return; + var stampResult = await RequestStampOptionsAsync(true); + if (stampResult is null) + { + StatusText = "Подписание отменено (диалог штампа закрыт)."; + return; + } + + SaveStampOptions(stampResult); + stampParameters = stampResult.Options; + } + // Перед подписанием с МЧД перепроверяем её против фактического сертификата. if (Poa is { } poaCheck) { @@ -892,6 +1055,7 @@ private async Task SignAllAsync() StampSignCopy = StampSignCopy, StampWithDate = StampWithDate, StampLogoPath = StampLogoPath, + StampParameters = stampParameters, PowerOfAttorney = Poa, }; var result = await Task.Run( diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml index a57b140..e1533e9 100644 --- a/src/SignService/Views/MainWindow.axaml +++ b/src/SignService/Views/MainWindow.axaml @@ -9,7 +9,7 @@ WindowStartupLocation="CenterScreen" DragDrop.AllowDrop="True"> - + @@ -72,53 +72,17 @@ IsChecked="{Binding MergeWithExisting}" Content="Объединять с существующей .sig" ToolTip.Tip="Соподписание: если рядом с документом уже есть .sig, ваша подпись добавится к имеющимся подписантам, а не заменит их. Подписи других лиц можно также приложить кнопкой «+.sig» у файла."/> - - - - - - - - - - - + ToolTip.Tip="Перед подписанием откроется диалог параметров штампа (страницы, дата, лого). По умолчанию подписывается ОРИГИНАЛ, а копия «имя (со штампом).pdf» — отдельный файл без подписи с плашками всех подписантов (безопасно при соподписании)."/> - + @@ -236,15 +230,12 @@ HorizontalAlignment="Right" FontWeight="SemiBold" Padding="24,6"/> - + + + + + + + + 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 4/9] 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; отчёт по каждому From e72030a73861d3b651b270c91afb1b93e6a2719e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:36:52 +0000 Subject: [PATCH 5/9] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B8=D0=B7=20PR=20#2=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8,=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=BD=D0=B0=D0=B4?= =?UTF-8?q?=D1=91=D0=B6=D0=BD=D0=BE=D0=B5=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Разделить групповую подпись» и «Исключить подписанта» — в меню «Инструменты» и в контекстном меню файла (для «имя.sig» рядом); подписанты различаются по SignerIdentifier даже при одинаковых ФИО, последнего подписанта исключить нельзя, исходный файл не меняется. - Приложенная подпись другого лица или смена сертификата возвращают подписанный файл в очередь (смена определяется по отпечатку — обновление списка сертификатов статусы не сбрасывает). - Автообновление: помощник PowerShell с резервной копией, повторами замены занятого exe и откатом при неудачном запуске. - Настройки и хранилище PFX можно открыть в отдельной папке (тесты больше не пишут в %AppData%). - Контекстные команды недоступны во время подписания. - CI: тесты на Windows перед сборкой exe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BtHSRXSuM8qTBTUBe29624 --- .github/workflows/build.yml | 4 + src/SignService/Services/AppSettings.cs | 16 +- src/SignService/Services/CertificateVault.cs | 27 ++- src/SignService/Services/CmsExtractor.cs | 35 +++ src/SignService/Services/CmsMerger.cs | 44 ++++ src/SignService/Services/UpdateService.cs | 136 +++++++++-- .../ViewModels/MainWindowViewModel.cs | 179 +++++++++++++- src/SignService/ViewModels/SignFileItem.cs | 12 + src/SignService/Views/MainWindow.axaml | 10 + src/SignService/Views/MainWindow.axaml.cs | 48 ++++ src/SignService/Views/SignerDialog.axaml | 16 ++ src/SignService/Views/SignerDialog.axaml.cs | 24 ++ tests/SignService.Tests/Program.cs | 12 +- .../ReviewRegressionTests.cs | 222 ++++++++++++++++++ 14 files changed, 739 insertions(+), 46 deletions(-) create mode 100644 src/SignService/Views/SignerDialog.axaml create mode 100644 src/SignService/Views/SignerDialog.axaml.cs create mode 100644 tests/SignService.Tests/ReviewRegressionTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a8a626..0b2ca06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,10 @@ jobs: with: dotnet-version: 8.0.x + # Windows-ветки: CryptoAPI-подпись, хранилище сертификатов, помощник автообновления. + - name: Run Windows test suite (CryptoAPI and update rollback) + run: dotnet run --project tests/SignService.Tests -c Release + - name: Publish self-contained single-file exe run: > dotnet publish src/SignService -c Release -r win-x64 diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs index 60eb222..ea201cb 100644 --- a/src/SignService/Services/AppSettings.cs +++ b/src/SignService/Services/AppSettings.cs @@ -18,6 +18,18 @@ public class AppSettings private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json"); + private readonly string _settingsDirectory = SettingsDir; + private readonly string _settingsPath = SettingsPath; + + public AppSettings() { } + + /// Изолированное хранилище настроек (например, для интеграционных тестов). + public AppSettings(string settingsDirectory) + { + _settingsDirectory = Path.GetFullPath(settingsDirectory); + _settingsPath = Path.Combine(_settingsDirectory, "settings.json"); + } + /// Отпечаток сертификата подписи по умолчанию. public string? SignCertThumbprint { get; set; } @@ -104,8 +116,8 @@ public void Save() { try { - Directory.CreateDirectory(SettingsDir); - File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + Directory.CreateDirectory(_settingsDirectory); + File.WriteAllText(_settingsPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); } catch { diff --git a/src/SignService/Services/CertificateVault.cs b/src/SignService/Services/CertificateVault.cs index 8029c4b..ed1a18a 100644 --- a/src/SignService/Services/CertificateVault.cs +++ b/src/SignService/Services/CertificateVault.cs @@ -20,9 +20,18 @@ namespace SignService.Services; /// public class CertificateVault { - private static readonly string VaultDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SignService", "certificates"); + private readonly string _vaultDirectory; + + /// Папка PFX; null — %AppData%/SignService/certificates + /// (другая папка — для изолированных интеграционных тестов). + public CertificateVault(string? vaultDirectory = null) + { + _vaultDirectory = vaultDirectory is not null + ? Path.GetFullPath(vaultDirectory) + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SignService", "certificates"); + } /// /// Сохраняет сертификат с закрытым ключом в защищённый паролем PFX. @@ -47,9 +56,9 @@ public SavedCertificateInfo Save(X509Certificate2 certificate, string password, + "Сохранить такой сертификат на компьютер нельзя.", e); } - Directory.CreateDirectory(VaultDir); + Directory.CreateDirectory(_vaultDirectory); var fileName = certificate.Thumbprint + ".pfx"; - File.WriteAllBytes(Path.Combine(VaultDir, fileName), pfx); + File.WriteAllBytes(Path.Combine(_vaultDirectory, fileName), pfx); var info = new SavedCertificateInfo { @@ -72,7 +81,7 @@ public SavedCertificateInfo Save(X509Certificate2 certificate, string password, /// Сохранённые сертификаты, PFX-файлы которых существуют на диске. public IReadOnlyList List(AppSettings settings) => settings.SavedCertificates - .Where(c => File.Exists(Path.Combine(VaultDir, c.FileName))) + .Where(c => File.Exists(Path.Combine(_vaultDirectory, c.FileName))) .ToList(); /// Открытая часть сохранённого сертификата (без ключа и без пароля). @@ -85,7 +94,7 @@ public static X509Certificate2 PublicPart(SavedCertificateInfo info) => /// public X509Certificate2 Load(SavedCertificateInfo info, string password) { - var path = Path.Combine(VaultDir, info.FileName); + var path = Path.Combine(_vaultDirectory, info.FileName); if (!File.Exists(path)) throw new InvalidOperationException("Файл сохранённого сертификата не найден: " + path); @@ -169,7 +178,7 @@ public void RemoveFromStore(string thumbprint, AppSettings settings) /// Загрузка PFX с флагами для установки в хранилище (ключ сохраняется). private X509Certificate2 LoadForStore(SavedCertificateInfo info, string password) { - var path = Path.Combine(VaultDir, info.FileName); + var path = Path.Combine(_vaultDirectory, info.FileName); try { return new X509Certificate2(path, password, @@ -184,7 +193,7 @@ private X509Certificate2 LoadForStore(SavedCertificateInfo info, string password /// Удаляет сохранённый сертификат с компьютера (PFX-файл и запись). public void Delete(SavedCertificateInfo info, AppSettings settings) { - var path = Path.Combine(VaultDir, info.FileName); + var path = Path.Combine(_vaultDirectory, info.FileName); if (File.Exists(path)) { // затираем содержимое перед удалением — в файле лежал закрытый ключ diff --git a/src/SignService/Services/CmsExtractor.cs b/src/SignService/Services/CmsExtractor.cs index 711a36c..ff2c284 100644 --- a/src/SignService/Services/CmsExtractor.cs +++ b/src/SignService/Services/CmsExtractor.cs @@ -13,6 +13,41 @@ namespace SignService.Services; /// public static class CmsExtractor { + public sealed record SignerInfo(string Id, string DisplayName); + + public sealed record RemoveSignerResult(string OutputPath, int SignerCount); + + /// Подписанты файла подписи — для выбора исключаемого. + public static IReadOnlyList ListSigners(string signaturePath) => + CmsMerger.GetSigners(File.ReadAllBytes(signaturePath)) + .Select((s, index) => new SignerInfo(s.Id, $"{index + 1}. {s.Name} — сертификат {s.CertificateId}")) + .ToList(); + + /// + /// Разделяет групповую подпись на отдельные откреплённые .sig по подписантам; + /// вложенный документ прикреплённого контейнера сохраняется рядом. + /// + public static ExtractionResult SplitSignatureFile(string signaturePath) + { + if (CmsMerger.CountSigners(File.ReadAllBytes(signaturePath)) < 2) + throw new InvalidOperationException("В файле меньше двух подписантов — это не групповая подпись."); + return ExtractToFiles(signaturePath); + } + + /// + /// Создаёт рядом новый файл «имя (без подписанта).sig» без выбранного подписанта. + /// Исходный файл не меняется. + /// + public static RemoveSignerResult RemoveSignerFromFile(string signaturePath, string signerId) + { + var signature = CmsMerger.RemoveSigner(File.ReadAllBytes(signaturePath), signerId); + var directory = Path.GetDirectoryName(signaturePath) ?? "."; + var output = UniquePath(directory, + Path.GetFileNameWithoutExtension(signaturePath) + " (без подписанта).sig", new[] { signaturePath }); + AtomicFile.Write(output, signature); + return new RemoveSignerResult(output, CmsMerger.CountSigners(signature)); + } + public sealed record ExtractionResult( string ContainerName, bool WasAttached, diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs index 6dcd469..3da8830 100644 --- a/src/SignService/Services/CmsMerger.cs +++ b/src/SignService/Services/CmsMerger.cs @@ -228,6 +228,50 @@ public static ContainerInfo Inspect(byte[] signature) return new ContainerInfo(parsed.HasContent, names); } + /// + /// Подписант контейнера: Id — DER SignerIdentifier в hex (устойчив при + /// одинаковых ФИО), CertificateId — серийный номер сертификата (или SKI). + /// + public sealed record SignerDescriptor(string Id, string Name, string CertificateId); + + public static IReadOnlyList GetSigners(byte[] signature) + { + var parsed = Parse(Normalize(signature)); + var index = BuildCertIndex(new List { parsed }); + return parsed.Signers.Select(s => + { + var reader = new AsnReader(Convert.FromHexString(s.SidKey), AsnEncodingRules.BER); + string identifier; + if (reader.PeekTag().TagClass == TagClass.Universal) + { + var sid = reader.ReadSequence(); + sid.ReadEncodedValue(); // issuer + identifier = Convert.ToHexString(sid.ReadIntegerBytes().Span); + } + else + { + identifier = s.SidKey; // [0] subjectKeyIdentifier + } + + return new SignerDescriptor(s.SidKey, SignerDisplayName(s.Der, index), identifier); + }).ToList(); + } + + /// + /// Удаляет SignerInfo по идентификатору (см. ), сохраняя + /// документ, сертификаты и остальных подписантов. Последнего подписанта удалить нельзя. + /// + public static byte[] RemoveSigner(byte[] signature, string signerId) + { + var parsed = Parse(Normalize(signature)); + if (!parsed.Signers.Any(s => s.SidKey == signerId)) + throw new InvalidOperationException("Выбранного подписанта нет в этом файле подписи."); + parsed.Signers.RemoveAll(s => s.SidKey == signerId); + if (parsed.Signers.Count == 0) + throw new InvalidOperationException("Нельзя исключить последнего подписанта: подпись станет пустой."); + return BuildMerged(new List { parsed }); + } + /// /// Извлекает вложенный документ из прикреплённой подписи; /// null — если подпись откреплённая (документа внутри нет). diff --git a/src/SignService/Services/UpdateService.cs b/src/SignService/Services/UpdateService.cs index dee39f4..95b6d6e 100644 --- a/src/SignService/Services/UpdateService.cs +++ b/src/SignService/Services/UpdateService.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -13,8 +14,9 @@ namespace SignService.Services; /// /// Автообновление программы через GitHub Releases: проверка последнего релиза, -/// скачивание нового SignService.exe и самозамена (Windows) через командный -/// скрипт, который дожидается выхода программы, подменяет exe и запускает его. +/// скачивание нового SignService.exe и самозамена (Windows) через помощник +/// PowerShell, который дожидается выхода программы и заменяет exe с резервной +/// копией и откатом при неудаче. /// public class UpdateService { @@ -104,7 +106,7 @@ public async Task DownloadAsync(UpdateInfo update, CancellationToken can if (update.ExeDownloadUrl is null) throw new InvalidOperationException("В релизе нет файла SignService.exe."); - var tempPath = Path.Combine(Path.GetTempPath(), $"SignService-{update.Version}.exe"); + var tempPath = Path.Combine(Path.GetTempPath(), $"SignService-{update.Version}-{Guid.NewGuid():N}.exe"); var bytes = await Http.GetByteArrayAsync(update.ExeDownloadUrl, cancellationToken); if (bytes.Length < 1024 * 1024) throw new InvalidOperationException("Скачанный файл подозрительно мал — обновление прервано."); @@ -113,8 +115,9 @@ public async Task DownloadAsync(UpdateInfo update, CancellationToken can } /// - /// Запускает самозамену (только Windows): скрипт дожидается завершения программы, - /// заменяет текущий exe скачанным и запускает новую версию. Вызывающий должен + /// Запускает самозамену (только Windows): помощник дожидается завершения программы, + /// заменяет текущий exe скачанным (старый сохраняется как .bak) и запускает новую + /// версию; если запуск не удался — возвращает прежний exe. Вызывающий должен /// закрыть приложение сразу после вызова. /// public void ApplyAndRestart(string downloadedExePath) @@ -125,26 +128,115 @@ public void ApplyAndRestart(string downloadedExePath) var currentExe = Environment.ProcessPath ?? throw new InvalidOperationException("Не удалось определить путь к текущему exe."); + if (!Path.GetFileName(currentExe).Equals("SignService.exe", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Автообновление доступно при запуске SignService.exe. Скачайте релиз вручную."); + if (!File.Exists(downloadedExePath)) + throw new FileNotFoundException("Файл обновления не найден.", downloadedExePath); - var script = Path.Combine(Path.GetTempPath(), "signservice_update.cmd"); - // Ждём, пока exe освободится (программа закрывается), затем подменяем и запускаем. - File.WriteAllText(script, $""" - @echo off - :wait - timeout /t 1 /nobreak >nul - del "{currentExe}" 2>nul - if exist "{currentExe}" goto wait - move /y "{downloadedExePath}" "{currentExe}" >nul - start "" "{currentExe}" - del "%~f0" - """); - - Process.Start(new ProcessStartInfo + var script = BuildInstallScript(currentExe, downloadedExePath, Environment.ProcessId); + + _ = Process.Start(new ProcessStartInfo { - FileName = "cmd.exe", - Arguments = $"/c \"{script}\"", + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), CreateNoWindow = true, UseShellExecute = false, - }); + }) ?? throw new InvalidOperationException("Не удалось запустить установщик обновления."); + } + + // Пути передаются как данные JSON/base64, без интерполяции в команды оболочки. + // Замена повторяется до replaceTimeoutMs: exe может ещё держать завершающийся + // процесс или антивирус. Этот же помощник проверяется интеграционными тестами. + internal static string BuildInstallScript(string currentExe, string downloadedExePath, + int processId, bool restart = true, int replaceTimeoutMs = 30_000) + { + var config = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new + { + current = Path.GetFullPath(currentExe), + downloaded = Path.GetFullPath(downloadedExePath), + parent = processId, + restart, + replaceTimeoutMs, + }))); + return $$""" + $ErrorActionPreference = 'Stop' + $config = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{config}}')) | ConvertFrom-Json + $stage = $config.current + '.' + [Guid]::NewGuid().ToString('N') + '.new' + $backup = $config.current + '.' + [Guid]::NewGuid().ToString('N') + '.bak' + $log = $config.downloaded + '.update.log' + $replaced = $false + $parentExited = $config.parent -le 0 + try { + if (-not [IO.File]::Exists($config.current)) { throw 'Current executable is missing' } + # Подготавливаем полную копию на том же диске до изменения текущего EXE. + [IO.File]::Copy($config.downloaded, $stage, $false) + if (([IO.FileInfo]::new($stage)).Length -ne ([IO.FileInfo]::new($config.downloaded)).Length) { + throw 'Incomplete update copy' + } + $deadline = [DateTime]::UtcNow.AddSeconds(30) + if (-not $parentExited) { + $parentProcess = Get-Process -Id $config.parent -ErrorAction SilentlyContinue + if ($null -eq $parentProcess) { $parentExited = $true } + else { + while (-not $parentProcess.HasExited -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 250 + } + $parentExited = $parentProcess.HasExited + } + } + if (-not $parentExited) { throw 'Timed out waiting for SignService to exit' } + # Replace атомарен: при ошибке исходный EXE остаётся. Файл может быть ещё + # занят завершающимся процессом или антивирусом — повторяем до таймаута. + $replaceDeadline = [DateTime]::UtcNow.AddMilliseconds($config.replaceTimeoutMs) + while ($true) { + try { + if ([IO.File]::Exists($config.current)) { + [IO.File]::Replace($stage, $config.current, $backup) + } + else { + # Replace успел переименовать старый EXE в резервную копию. + [IO.File]::Move($stage, $config.current) + } + break + } + catch { + if ([DateTime]::UtcNow -ge $replaceDeadline) { throw } + Start-Sleep -Milliseconds 250 + } + } + $replaced = $true + if ($config.restart) { + [Diagnostics.Process]::Start($config.current) | Out-Null + } + [IO.File]::WriteAllText($log, 'Update installed. Backup: ' + $backup) + } + catch { + $failure = $_.Exception.Message + if ($replaced -and [IO.File]::Exists($backup)) { + try { + $failedUpdate = $stage + '.failed' + [IO.File]::Replace($backup, $config.current, $failedUpdate) + [IO.File]::Delete($failedUpdate) + } + catch { $failure += '; rollback failed: ' + $_.Exception.Message + '; backup: ' + $backup } + } + elseif (-not [IO.File]::Exists($config.current) -and [IO.File]::Exists($backup)) { + try { [IO.File]::Move($backup, $config.current) } + catch { $failure += '; restore failed: ' + $_.Exception.Message + '; backup: ' + $backup } + } + try { [IO.File]::WriteAllText($log, $failure) } catch { } + if ($config.restart -and $parentExited -and [IO.File]::Exists($config.current)) { + try { [Diagnostics.Process]::Start($config.current) | Out-Null } catch { } + } + exit 1 + } + finally { + if ([IO.File]::Exists($stage)) { + try { [IO.File]::Delete($stage) } catch { } + } + } + """; } } diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs index cd28b85..8e2bb49 100644 --- a/src/SignService/ViewModels/MainWindowViewModel.cs +++ b/src/SignService/ViewModels/MainWindowViewModel.cs @@ -13,15 +13,27 @@ public partial class MainWindowViewModel : ObservableObject { private readonly CertificateProvider _certificateProvider; private readonly DocumentSigner _documentSigner; - private readonly CertificateVault _vault = new(); + private readonly CertificateVault _vault; private readonly AppSettings _settings; private bool _initializing; + // Отпечаток выбранного сертификата: список пересоздаёт элементы при обновлении, + // поэтому смену сертификата определяем по отпечатку, а не по экземпляру. + private string? _activeThumbprint; + public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSigner documentSigner) + : this(certificateProvider, documentSigner, AppSettings.Load()) + { + } + + /// С явными настройками и папкой сохранённых сертификатов — для тестов. + public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSigner documentSigner, + AppSettings settings, CertificateVault? vault = null) { _certificateProvider = certificateProvider; _documentSigner = documentSigner; - _settings = AppSettings.Load(); + _settings = settings; + _vault = vault ?? new CertificateVault(); _initializing = true; IsDetached = _settings.DetachedSignature; @@ -155,6 +167,14 @@ private async Task CheckUpdatesOnStartAsync() [NotifyCanExecuteChangedFor(nameof(MergeFilesCommand))] [NotifyCanExecuteChangedFor(nameof(BuildContainerCommand))] [NotifyCanExecuteChangedFor(nameof(VerifySignatureCommand))] + [NotifyCanExecuteChangedFor(nameof(SplitSignaturesCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveSignerCommand))] + [NotifyCanExecuteChangedFor(nameof(AttachSignaturesCommand))] + [NotifyCanExecuteChangedFor(nameof(StampItemCommand))] + [NotifyCanExecuteChangedFor(nameof(ExtractItemCommand))] + [NotifyCanExecuteChangedFor(nameof(BuildContainerItemCommand))] + [NotifyCanExecuteChangedFor(nameof(SplitItemCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveSignerItemCommand))] private bool _isBusy; [ObservableProperty] @@ -204,6 +224,15 @@ private void Log(string message) /// Запрос диалога выбора документа для сборки криптоконтейнера. public event EventHandler? BuildContainerRequested; + /// Запрос диалога выбора групповых подписей для разделения. + public event EventHandler? SplitSignaturesRequested; + + /// Запрос диалога выбора подписи, из которой исключается подписант. + public event EventHandler? RemoveSignerRequested; + + /// Выбор исключаемого подписанта (реализуется окном): Id или null при отмене. + public Func, Task>? RequestSignerChoiceAsync { get; set; } + /// /// Открыть окно «Проверка ЭЦП» (реализуется окном). Пути подписи и документа /// необязательны: если подпись указана, проверка запускается сразу. @@ -227,7 +256,28 @@ private void Log(string message) // сертификатом без повторного выбора. partial void OnSelectedCertificateChanged(CertificateItem? value) { - if (_initializing || value is null) + if (value is null) + return; + + // Смена сертификата позволяет соподписать уже обработанные файлы. Обновление + // списка (тот же отпечаток, новый элемент) статусы не сбрасывает. + var changed = _activeThumbprint is not null + && !string.Equals(_activeThumbprint, value.Thumbprint, StringComparison.OrdinalIgnoreCase); + _activeThumbprint = value.Thumbprint; + if (changed) + { + var requeued = 0; + foreach (var file in Files.Where(f => f.Status == SignStatus.Signed)) + { + file.ResetForSigning(); + requeued++; + } + + if (requeued > 0) + Log($"Сертификат сменён: подписанные файлы ({requeued}) снова в очереди — для соподписания."); + } + + if (_initializing) return; _settings.SignCertThumbprint = value.Thumbprint; _settings.Save(); @@ -665,7 +715,7 @@ public void AddFiles(params string[] filePaths) } /// Открыть диалог выбора подписей других лиц для файла. - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanBrowse))] private void AttachSignatures(SignFileItem item) => AttachSignaturesRequested?.Invoke(this, item); [RelayCommand(CanExecute = nameof(CanBrowse))] @@ -817,7 +867,7 @@ private async Task InspectSignaturesAsync(SignFileItem item) } /// ПКМ: штамп для файла из списка (диалог параметров → копия без подписи). - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanBrowse))] private async Task StampItemAsync(SignFileItem item) { if (RequestStampOptionsAsync is null) @@ -830,7 +880,7 @@ private async Task StampItemAsync(SignFileItem item) } /// ПКМ: извлечь из контейнера «имя.sig» рядом с файлом. - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanBrowse))] private async Task ExtractItemAsync(SignFileItem item) { var sigPath = item.FilePath + ".sig"; @@ -844,10 +894,123 @@ private async Task ExtractItemAsync(SignFileItem item) } /// ПКМ: собрать криптоконтейнер из файла и его подписи рядом. - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanBrowse))] private Task BuildContainerItemAsync(SignFileItem item) => BuildContainerAsync(item.FilePath, null); + /// ПКМ: разделить групповую подпись «имя.sig» рядом с файлом по подписантам. + [RelayCommand(CanExecute = nameof(CanBrowse))] + private async Task SplitItemAsync(SignFileItem item) + { + var sigPath = item.FilePath + ".sig"; + if (!System.IO.File.Exists(sigPath)) + { + StatusText = $"Рядом с «{item.FileName}» нет файла подписи для разделения."; + return; + } + + await SplitSignatureFilesAsync(new[] { sigPath }); + } + + /// ПКМ: исключить подписанта из «имя.sig» рядом с файлом. + [RelayCommand(CanExecute = nameof(CanBrowse))] + private async Task RemoveSignerItemAsync(SignFileItem item) + { + var sigPath = item.FilePath + ".sig"; + if (!System.IO.File.Exists(sigPath)) + { + StatusText = $"Рядом с «{item.FileName}» нет файла подписи."; + return; + } + + await RemoveSignerFromFileAsync(sigPath); + } + + [RelayCommand(CanExecute = nameof(CanBrowse))] + private void SplitSignatures() => SplitSignaturesRequested?.Invoke(this, EventArgs.Empty); + + [RelayCommand(CanExecute = nameof(CanBrowse))] + private void RemoveSigner() => RemoveSignerRequested?.Invoke(this, EventArgs.Empty); + + /// Разделяет групповые подписи на отдельные .sig по подписантам. + public async Task SplitSignatureFilesAsync(IReadOnlyList paths) + { + if (paths.Count == 0) + return; + + IsBusy = true; + try + { + foreach (var path in paths) + { + var name = System.IO.Path.GetFileName(path); + try + { + var r = await Task.Run(() => CmsExtractor.SplitSignatureFile(path)); + StatusText = $"«{name}»: создано отдельных подписей: {r.SignerFiles.Count} (" + + string.Join(", ", r.SignerFiles.Select(System.IO.Path.GetFileName)) + ")" + + (r.DocumentPath is null ? "" : $"; документ → {System.IO.Path.GetFileName(r.DocumentPath)}"); + } + catch (Exception ex) + { + StatusText = $"Разделение «{name}»: {ex.Message}"; + } + } + } + finally + { + IsBusy = false; + } + } + + /// + /// Исключает подписанта: выбор в диалоге → новый файл «имя (без подписанта).sig» + /// с остальными подписями. Исходный файл не меняется. + /// + public async Task RemoveSignerFromFileAsync(string path) + { + if (RequestSignerChoiceAsync is null) + return; + + var name = System.IO.Path.GetFileName(path); + IReadOnlyList signers; + try + { + signers = await Task.Run(() => CmsExtractor.ListSigners(path)); + } + catch (Exception ex) + { + StatusText = $"Не удалось прочитать подпись «{name}»: {ex.Message}"; + return; + } + + if (signers.Count < 2) + { + StatusText = $"«{name}»: подписант один — последнего подписанта исключить нельзя."; + return; + } + + var signerId = await RequestSignerChoiceAsync(signers); + if (signerId is null) + return; + + IsBusy = true; + try + { + var r = await Task.Run(() => CmsExtractor.RemoveSignerFromFile(path, signerId)); + StatusText = $"Подписант исключён → «{System.IO.Path.GetFileName(r.OutputPath)}», " + + $"осталось подписантов: {r.SignerCount}. Исходный «{name}» не изменён."; + } + catch (Exception ex) + { + StatusText = "Не удалось исключить подписанта: " + ex.Message; + } + finally + { + IsBusy = false; + } + } + /// ПКМ: открыть папку файла. [RelayCommand] private void OpenFolder(SignFileItem item) @@ -1023,7 +1186,7 @@ private async Task SignAllAsync() { Detached = IsDetached, MergeWithExisting = MergeWithExisting, - ExtraSignatures = file.ExtraSignatures, + ExtraSignatures = file.ExtraSignatures.ToList(), Timestamp = UseTimestamp, TsaUrl = TsaUrl, Stamp = UseStamp, diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs index 896428d..4a7d77b 100644 --- a/src/SignService/ViewModels/SignFileItem.cs +++ b/src/SignService/ViewModels/SignFileItem.cs @@ -80,9 +80,21 @@ public int AttachSignatures(IEnumerable sigPaths) } ExtraCount = _extraSignatures.Count; + // Новая подпись другого лица должна попасть в .sig — файл снова ждёт подписания. + if (added > 0 && Status == SignStatus.Signed) + ResetForSigning(); return added; } + /// Возвращает файл в очередь (после смены сертификата или новых подписей). + public void ResetForSigning() + { + Status = SignStatus.Pending; + Message = null; + SignaturePath = null; + SignerCount = 0; + } + public string StatusDisplay => Status switch { SignStatus.Pending => "Ожидает", diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml index 47b36bb..d7b17b5 100644 --- a/src/SignService/Views/MainWindow.axaml +++ b/src/SignService/Views/MainWindow.axaml @@ -152,6 +152,12 @@ + + + + await BrowseStampOnlyAsync(vm); vm.BuildContainerRequested += async (_, _) => await BrowseBuildContainerAsync(vm); vm.AddPoaRequested += async (_, _) => await BrowsePoaAsync(vm); + vm.SplitSignaturesRequested += async (_, _) => await BrowseSplitSignaturesAsync(vm); + vm.RemoveSignerRequested += async (_, _) => await BrowseRemoveSignerAsync(vm); + vm.RequestSignerChoiceAsync = async signers => + await new SignerDialog(signers).ShowDialog(this); vm.RequestStampOptionsAsync = async showSignCopy => await new StampOptionsDialog(vm.BuildInitialStampOptions(), vm.StampSignCopy, showSignCopy) .ShowDialog(this); @@ -331,4 +335,48 @@ await vm.ExtractContainersAsync(files .Select(p => p!) .ToList()); } + + private async System.Threading.Tasks.Task BrowseSplitSignaturesAsync(MainWindowViewModel vm) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Групповые подписи для разделения по подписантам", + AllowMultiple = true, + FileTypeFilter = new[] + { + new FilePickerFileType("Подписи CMS (*.sig, *.p7s, *.p7m)") + { + Patterns = new[] { "*.sig", "*.p7s", "*.p7m" }, + }, + FilePickerFileTypes.All, + }, + }); + + await vm.SplitSignatureFilesAsync(files + .Select(f => f.TryGetLocalPath()) + .Where(p => p is not null) + .Select(p => p!) + .ToList()); + } + + private async System.Threading.Tasks.Task BrowseRemoveSignerAsync(MainWindowViewModel vm) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Подпись, из которой нужно исключить подписанта", + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("Подписи CMS (*.sig, *.p7s, *.p7m)") + { + Patterns = new[] { "*.sig", "*.p7s", "*.p7m" }, + }, + FilePickerFileTypes.All, + }, + }); + + var path = files.FirstOrDefault()?.TryGetLocalPath(); + if (path is not null) + await vm.RemoveSignerFromFileAsync(path); + } } diff --git a/src/SignService/Views/SignerDialog.axaml b/src/SignService/Views/SignerDialog.axaml new file mode 100644 index 0000000..5929087 --- /dev/null +++ b/src/SignService/Views/SignerDialog.axaml @@ -0,0 +1,16 @@ + + + + + + +