Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ name: Build

on:
push:
branches: ["main", "claude/**"]
branches: ["main", "claude/**", "codex/**"]
tags: ["v*"]
pull_request:
branches: ["main"]
workflow_dispatch:
inputs:
release_tag:
Expand All @@ -30,6 +32,7 @@ jobs:

build-windows:
name: Windows x64 exe
needs: tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -38,6 +41,9 @@ jobs:
with:
dotnet-version: 8.0.x

- 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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## v1.1.0 — не выпущена

- **Управление групповой подписью**: отдельные пункты «Разделить групповую
подпись…» и «Исключить подписанта из .sig…». Выбор подписанта по имени и
номеру сертификата; исходный файл сохраняется, удаление последнего запрещено.
- **МЧД**: криптографическая проверка всех подписей, включая ГОСТ через
CryptoAPI; актуальные срок и представитель считываются из проверяемого XML.
Проверка и копирование используют одни байты; коллизия с другой МЧД
останавливает подписание до изменения существующей `.sig`.
- **Соподписание**: исправлено сохранение старого вложенного документа после
исключения устаревшего подписанта; выбранный режим откреплённой подписи
соблюдается и при объединении с прикреплённым контейнером.
- Добавление `.sig` и смена сертификата возвращают подписанные файлы в очередь.
- **Обновление**: атомарная замена EXE с резервной копией и откатом при отказе
запуска; ограниченное ожидание выхода, безопасная передача путей и журнал.
- Добавлены регрессионные тесты, настройки тестов изолированы от пользовательских;
сборка и публикация релиза в CI зависят от успешного выполнения тестов.

- **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается
ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно
без подписи — после подписания и со всеми подписантами итоговой подписи
Expand Down
20 changes: 17 additions & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- **Extract from .sig…** — pull out of a container: the embedded document
(byte-exact), a detached signature with all signers, and individual `.sig`
files per signer (signer names in the file names);
- **Split group signature…** — a dedicated Tools command creates a detached
`.sig` for every signer and saves the embedded document, if present.
Existing files are preserved and duplicate names receive a numeric suffix;
- **Remove signer from .sig…** — select a signer by name and certificate serial
number. A new signature file is created with the remaining signatures and
embedded document. The original file is kept; removing the last signer is blocked;
- **Build container…** — pack a document together with its existing
signatures into an attached `.sig` (the inverse of extraction);
- **Stamp PDF…** — save a stamped copy of a document without signing.
Expand All @@ -65,14 +71,19 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- the "Add power of attorney…" button works like Kontur: pick the MChD XML
(EMCHD_1 format) and the head's signature (.sig; a neighbouring
`name.xml.sig` is picked up automatically);
- the app verifies: the head's signature matches the MChD file (by hash,
Streebog for GOST), the validity period has not expired, and the
- the app cryptographically verifies all signatures over the MChD XML
(CryptoAPI with an installed CSP for GOST on Windows), checks the validity
period, and verifies that the
representative in the MChD matches the selected certificate by INN/SNILS;
- when signing, the MChD files (XML + .sig) are copied next to the signed
document (the MChD is NOT embedded into the CMS signature — Kontur does
the same), and the visual PDF stamp gains a
"Acting under power of attorney No. …" line;
- the power of attorney is remembered and re-validated on every signing.
- the XML and signature are re-read on every signing; validation and copying
use the same file snapshot. Conflicts with a different MChD in the output
directory stop signing before overwriting an existing document signature;
- this check does not validate CA trust, certificate revocation, the head's
authority, or the power of attorney's registry/revocation status.

### Visual stamp on PDF

Expand Down Expand Up @@ -107,6 +118,9 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- an **About window**: version, author contacts, changelog, update check;
- **auto-update**: new releases are checked on GitHub Releases at startup
(can be disabled) and installed in one click from the About window (Windows).
The executable is replaced atomically with a `.bak` backup and rollback if
launching fails. Waiting for exit is limited to 30 seconds; the update log
is saved beside the downloaded executable in the temporary directory.

## Requirements

Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
- **Извлечь из .sig…** — из криптоконтейнера достаются: вложенный документ
(байт-в-байт), откреплённая подпись со всеми подписантами и отдельные `.sig`
по каждому подписанту (в имени — ФИО из сертификата);
- **Разделить групповую подпись…** — отдельный пункт меню «Инструменты»:
создаёт откреплённую `.sig` для каждого подписанта; если документ был внутри
контейнера, сохраняет и его. При совпадении имён добавляется номер;
- **Исключить подписанта из .sig…** — выбор по ФИО и номеру сертификата,
результат сохраняется в новый файл «имя (без подписанта).sig»;
остальные подписи и вложенный документ сохраняются, последний подписант
не удаляется. Исходные файлы обеих операций остаются без изменений;
- **Собрать контейнер…** — документ упаковывается в прикреплённый `.sig`
вместе с имеющимися подписями (обратная операция к извлечению);
- **Штамп на PDF…** — копия документа с плашкой о подписании без подписи.
Expand All @@ -59,14 +66,19 @@
- кнопка «Добавить доверенность…» — как в Контуре: выбираются файл МЧД
(XML, формат EMCHD_1) и подпись руководителя (.sig; `имя.xml.sig` рядом
подхватывается автоматически);
- программа проверяет: подпись руководителя соответствует файлу МЧД
(по хешу, для ГОСТ — Стрибогом), срок действия не истёк, представитель
- программа проверяет криптографическую корректность всех подписей МЧД
(для ГОСТ на Windows — через CryptoAPI и установленный CSP), срок действия
не истёк, представитель
в МЧД совпадает с владельцем выбранного сертификата по ИНН/СНИЛС;
- при подписании файлы МЧД (XML + .sig) копируются рядом с подписанным
документом (в CMS-подпись МЧД не вкладывается — так же поступает Контур),
а в визуальный штамп на PDF добавляется строка
«Действует на основании МЧД № …»;
- доверенность запоминается и перепроверяется при каждом подписании.
- доверенность запоминается; при каждом подписании XML и подпись заново
считываются, проверяются и копируются из одного снимка файлов. При конфликте
с другим комплектом МЧД в выходном каталоге подписание останавливается;
- проверка МЧД не проверяет доверие цепочке УЦ, отзыв сертификата,
полномочия руководителя и отзыв доверенности в реестре.

### Визуальный штамп на PDF

Expand Down Expand Up @@ -103,6 +115,9 @@
проверка обновлений;
- **автообновление**: проверка новых версий на GitHub Releases при запуске
(отключается) и обновление в один клик из окна «О программе» (Windows).
Замена выполняется атомарно с резервной копией `.bak` рядом с EXE;
при отказе запуска выполняется откат. Ожидание выхода ограничено 30 секундами,
журнал установки сохраняется рядом со скачанным файлом во временном каталоге.

## Требования

Expand Down
16 changes: 14 additions & 2 deletions src/SignService/Services/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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() { }

/// <summary>Изолированное хранилище настроек (например, для интеграционных тестов).</summary>
public AppSettings(string settingsDirectory)
{
_settingsDirectory = Path.GetFullPath(settingsDirectory);
_settingsPath = Path.Combine(_settingsDirectory, "settings.json");
}

/// <summary>Отпечаток сертификата подписи по умолчанию.</summary>
public string? SignCertThumbprint { get; set; }

Expand Down Expand Up @@ -85,8 +97,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
{
Expand Down
7 changes: 6 additions & 1 deletion src/SignService/Services/CertificateVault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ namespace SignService.Services;
/// </summary>
public class CertificateVault
{
private static readonly string VaultDir = Path.Combine(
private readonly string VaultDir;

public CertificateVault(string? vaultDirectory = null)
{
VaultDir = vaultDirectory is not null ? Path.GetFullPath(vaultDirectory) : Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SignService", "certificates");
}

/// <summary>
/// Сохраняет сертификат с закрытым ключом в защищённый паролем PFX.
Expand Down
28 changes: 28 additions & 0 deletions src/SignService/Services/CmsExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ namespace SignService.Services;
/// </summary>
public static class CmsExtractor
{
public sealed record SignerInfo(string Id, string DisplayName);
public sealed record RemoveSignerResult(string OutputPath, int SignerCount);

public static IReadOnlyList<SignerInfo> ListSigners(string signaturePath) =>
CmsMerger.GetSigners(File.ReadAllBytes(signaturePath))
.Select((s, index) => new SignerInfo(s.Id, $"{index + 1}. {s.Name} — сертификат {s.CertificateId}"))
.ToList();

/// <summary>Отдельные откреплённые .sig; вложенный документ сохраняется рядом.</summary>
public static ExtractionResult SplitSignatureFile(string signaturePath)
{
if (CmsMerger.CountSigners(File.ReadAllBytes(signaturePath)) < 2)
throw new InvalidOperationException("В файле меньше двух подписантов — это не групповая подпись.");
return ExtractToFiles(signaturePath);
}

/// <summary>Создаёт новый контейнер без выбранного подписанта. Исходный файл сохраняется.</summary>
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 });
using (var stream = new FileStream(output, FileMode.CreateNew, FileAccess.Write))
stream.Write(signature);
return new RemoveSignerResult(output, CmsMerger.CountSigners(signature));
}

public sealed record ExtractionResult(
string ContainerName,
bool WasAttached,
Expand Down
48 changes: 47 additions & 1 deletion src/SignService/Services/CmsMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,17 @@ public static MergeResult MergeForDocument(IReadOnlyList<byte[]> signatures, byt
throw new InvalidOperationException(
"Все объединяемые подписи не соответствуют текущему содержимому документа.");

var merged = BuildMerged(parsed);
// Содержимое входного контейнера может относиться к исключённой старой
// подписи. Собираем результат только с текущим документом.
var attached = parsed.Any(p => p.HasContent);
foreach (var p in parsed)
{
p.EncapContentInfo = StripContent(p.EncapContentInfo);
p.HasContent = false;
}
var merged = BuildMerged(parsed.Where(p => p.Signers.Count > 0).ToList());
if (attached)
merged = AttachContent(merged, document);
return new MergeResult(
merged,
CountSigners(merged),
Expand Down Expand Up @@ -190,6 +200,42 @@ public static ContainerInfo Inspect(byte[] signature)
return new ContainerInfo(parsed.HasContent, names);
}

public sealed record SignerDescriptor(string Id, string Name, string CertificateId);

public static IReadOnlyList<SignerDescriptor> GetSigners(byte[] signature)
{
var parsed = Parse(Normalize(signature));
var index = BuildCertIndex(new List<ParsedSignedData> { 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; // subjectKeyIdentifier
}
return new SignerDescriptor(s.SidKey, SignerDisplayName(s.Der, index), identifier);
}).ToList();
}

/// <summary>Удаляет SignerInfo по устойчивому идентификатору, сохраняя документ и остальные подписи.</summary>
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<ParsedSignedData> { parsed });
}

/// <summary>
/// Извлекает вложенный документ из прикреплённой подписи;
/// null — если подпись откреплённая (документа внутри нет).
Expand Down
36 changes: 36 additions & 0 deletions src/SignService/Services/CmsVerifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;

namespace SignService.Services;

/// <summary>Криптографическая проверка всех подписантов; доверие УЦ и отзыв проверяются отдельно.</summary>
internal static class CmsVerifier
{
public static void VerifyDocument(byte[] signature, byte[] document)
{
var normalized = CmsMerger.Normalize(signature);
var embedded = CmsMerger.ExtractContent(normalized);
if (embedded is not null && !embedded.AsSpan().SequenceEqual(document))
throw new CryptographicException("Вложенный документ не соответствует файлу МЧД.");
var signers = CmsMerger.SplitBySigner(normalized);
if (signers.Count == 0)
throw new CryptographicException("В файле нет подписантов.");

// Проверяем каждый исходный SignerInfo, включая повторные записи одного SID.
foreach (var (_, detached) in signers)
{
if (OperatingSystem.IsWindows())
{
// CryptoAPI передаёт ГОСТ-проверку установленному CSP, как и подписание.
NativeSign.VerifyDetached(detached, document, 0);
}
else
{
var cms = new SignedCms(new ContentInfo(document), detached: true);
cms.Decode(detached);
cms.CheckSignature(verifySignatureOnly: true);
}
}
}
}
Loading
Loading