Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# История версий SignService

## v1.4.1 — 20.10.2026

- **Чужая подпись не теряется при одновременном соподписании.** Объединение
и контроль изменений используют один снимок файла, снятый до PIN-кода и TSA.
Изменение, удаление или появление файла назначения останавливает запись,
в том числе в режиме «Создать новую». Перед атомарной заменой выполняется
повторная проверка; исчезнувшая выбранная подпись не подменяется другим путём.
- **Подписанная копия со штампом защищена для всех форматов CMS.** Учитываются
`.sig`, `.p7s`, `.p7m` и подписи с другими именами, найденные по содержимому
копии, включая обычный PKCS#7 без подписанных атрибутов. Проверка выполняется
и непосредственно перед сохранением PDF/Word, исходные файлы сохраняются.
- Добавлены регрессионные тесты конкурентной записи, удаления и появления
подписи, а также защиты подписанных копий PDF и Word.

## v1.4.0 — 20.10.2026

- **Штамп в документах Word (.docx, .docm).** С галочкой «Штамп на документ»
Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- before replacing a signature file without merging and before excluding
signers the app asks for confirmation and keeps a backup next to it; the
result shows in the file row: "Подписан: 9 (8 + ваша) → X.sig";
- if the destination signature changes, disappears or appears during signing,
publication stops and preserves the other version, in both signing modes;
- re-signing with the same certificate **replaces** your previous signature
instead of duplicating it;
- every merged signature is **cryptographically verified**, including the
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
- перед заменой файла подписи без объединения и перед исключением подписантов
программа спрашивает подтверждение и сохраняет резервную копию рядом;
итог виден в строке файла: «Подписан: 9 (8 + ваша) → X.sig»;
- если файл назначения изменился, исчез или появился во время подписания,
запись отменяется и чужая версия сохраняется — в обоих режимах подписания;
- повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует;
- каждая объединяемая подпись **проверяется криптографически**: хеш документа,
значение подписи и привязка сертификата CAdES — в том числе подписи КриптоПро
Expand Down
7 changes: 4 additions & 3 deletions src/SignService/Services/AtomicFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace SignService.Services;
internal static class AtomicFile
{
public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = true,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default, Action? beforeCommit = null)
{
var fullPath = Path.GetFullPath(path);
var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, ".signservice-" + Guid.NewGuid().ToString("N") + ".tmp");
Expand All @@ -22,6 +22,7 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite =
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
beforeCommit?.Invoke();
File.Move(temporary, fullPath, overwrite);
}
finally
Expand All @@ -30,6 +31,6 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite =
}
}

public static void Write(string path, byte[] bytes, bool overwrite = false) =>
WriteAsync(path, bytes, overwrite).GetAwaiter().GetResult();
public static void Write(string path, byte[] bytes, bool overwrite = false, Action? beforeCommit = null) =>
WriteAsync(path, bytes, overwrite, beforeCommit: beforeCommit).GetAwaiter().GetResult();
}
76 changes: 44 additions & 32 deletions src/SignService/Services/DocumentSigner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public sealed record SignOptions
/// </summary>
internal Action? BeforePublish { get; init; }

// Позволяет воспроизвести внешнее изменение после чтения входов, до подписи.
internal Action? AfterSignatureInputsRead { get; init; }

/// <summary>Пути к .sig других подписантов для объединения.</summary>
public IReadOnlyList<string> ExtraSignatures { get; init; } = Array.Empty<string>();

Expand Down Expand Up @@ -201,25 +204,14 @@ public async Task<SignFileResult> SignFileAsync(
: null;
var signaturePath = options.MergeWithExisting
&& !string.IsNullOrWhiteSpace(existingSignaturePath)
&& File.Exists(existingSignaturePath)
? existingSignaturePath
: canonicalSignaturePath;

// Свою подпись создаём ДО чтения объединяемых файлов, чтобы ошибка
// подписания не оставила .sig наполовину обработанным.
var own = Sign(data, certificate, options.Detached);

// Штамп времени TSA (CAdES-T) — неподписанный атрибут на значение подписи.
if (options.Timestamp)
{
if (string.IsNullOrWhiteSpace(options.TsaUrl))
throw new InvalidOperationException(
"Для подписи со штампом времени укажите адрес службы штампов времени (TSA).");

var token = await _timestampClient.RequestTokenAsync(
CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken);
own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token);
}
// Один снимок назначения — и для объединения, и для контроля изменений.
// Читаем до PIN/TSA, в том числе в режиме замены без объединения.
var previousBytes = await ReadExistingSignatureAsync(signaturePath, cancellationToken);
if (options.MergeWithExisting && !string.IsNullOrWhiteSpace(existingSignaturePath) && previousBytes is null)
throw new IOException("Выбранный файл подписи больше не существует. Повторите поиск подписей.");

var inputPaths = new List<string>();
void AddInputPath(string? path)
Expand All @@ -235,7 +227,7 @@ void AddInputPath(string? path)
if (options.MergeWithExisting)
{
if (!string.IsNullOrWhiteSpace(existingSignaturePath))
AddInputPath(existingSignaturePath);
inputPaths.Add(existingSignaturePath);
else
AddInputPath(canonicalSignaturePath);
}
Expand All @@ -245,20 +237,25 @@ void AddInputPath(string? path)

var inputs = new List<byte[]>();
foreach (var path in inputPaths)
inputs.Add(await File.ReadAllBytesAsync(path, cancellationToken));
inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает
inputs.Add(SignatureDiscovery.SamePath(path, signaturePath) && previousBytes is not null
? previousBytes : await File.ReadAllBytesAsync(path, cancellationToken));

var appended = inputPaths.Any(path => string.Equals(
Path.GetFullPath(path), Path.GetFullPath(signaturePath), StringComparison.OrdinalIgnoreCase));
// Снимок того, что вошло в объединение. Перед записью файл читается ещё раз:
// за время PIN-кода или пакетного подписания его могли обновить.
byte[]? previousBytes = null;
var previousSignerCount = 0;
if (appended)
var previousSignerCount = appended && previousBytes is not null ? CmsMerger.CountSigners(previousBytes) : 0;
options.AfterSignatureInputsRead?.Invoke();

var own = Sign(data, certificate, options.Detached);
if (options.Timestamp)
{
previousBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
previousSignerCount = CmsMerger.CountSigners(previousBytes);
if (string.IsNullOrWhiteSpace(options.TsaUrl))
throw new InvalidOperationException(
"Для подписи со штампом времени укажите адрес службы штампов времени (TSA).");
var token = await _timestampClient.RequestTokenAsync(
CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken);
own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token);
}
inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает

int signerCount;
IReadOnlyList<string> excluded;
Expand Down Expand Up @@ -291,16 +288,17 @@ void AddInputPath(string? path)
// Галочка добавляет копию и при обычном дописывании. Если файл успели
// изменить после чтения — не пишем ничего, в том числе файлы МЧД.
string? backupPath = null;
if (File.Exists(signaturePath))
var latest = await ReadExistingSignatureAsync(signaturePath, cancellationToken);
ThrowIfSignatureChanged(previousBytes, latest);
if (latest is not null)
{
var latest = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
ThrowIfSignatureChanged(previousBytes, latest);
var riskyOverwrite = !appended || excluded.Count > 0 || LosesEmbeddedDocument(latest, output);
if (riskyOverwrite || options.CreateBackup)
backupPath = SignatureBackup.Create(signaturePath);
}
if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath);
await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken);
await AtomicFile.WriteAsync(signaturePath, output, overwrite: previousBytes is not null, cancellationToken,
beforeCommit: () => ThrowIfSignatureChanged(previousBytes, ReadExistingSignature(signaturePath)));

// Режим «копия отдельно»: штампованная копия БЕЗ подписи с подписантами
// итогового .sig — пересоздаётся после каждого подписания. Плашки — только
Expand Down Expand Up @@ -341,13 +339,27 @@ void AddInputPath(string? path)
/// Отказ от записи, если файл подписи сменился после того, как его прочитали
/// для объединения. Иначе более новая чужая подпись была бы затёрта нашей копией.
/// </summary>
internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[] latest)
internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[]? latest)
{
if (snapshot is not null && !snapshot.AsSpan().SequenceEqual(latest))
if (snapshot is null ? latest is not null : latest is null || !snapshot.AsSpan().SequenceEqual(latest))
throw new IOException(
"Файл подписи изменился во время подписания. Подпись не сохранена; повторите операцию.");
}

private static async Task<byte[]?> ReadExistingSignatureAsync(string path, CancellationToken cancellationToken)
{
try { return await File.ReadAllBytesAsync(path, cancellationToken); }
catch (FileNotFoundException) { return null; }
catch (DirectoryNotFoundException) { return null; }
}

private static byte[]? ReadExistingSignature(string path)
{
try { return File.ReadAllBytes(path); }
catch (FileNotFoundException) { return null; }
catch (DirectoryNotFoundException) { return null; }
}

/// <summary>
/// true, если в существующей прикреплённой подписи есть документ, а в новой
/// записи его не будет или он станет другим. Сами значения подписей при этом
Expand Down
24 changes: 21 additions & 3 deletions src/SignService/Services/DocumentStamper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,35 @@ public static string StampedCopyPath(string filePath) =>
WordStamper.IsWord(filePath) ? WordStamper.StampedCopyPath(filePath) : PdfStamper.StampedCopyPath(filePath);

/// <summary>
/// Отказ, если у копии со штампом есть своя подпись («… (со штампом).pdf.sig»):
/// Отказ, если у копии со штампом есть своя подпись (в том числе с другим именем):
/// новая копия — другой файл, и та подпись перестала бы ему соответствовать.
/// </summary>
public static void ThrowIfCopySigned(string filePath)
{
var copy = StampedCopyPath(filePath);
if (!File.Exists(copy + ".sig"))
var signature = new[] { ".sig", ".p7s", ".p7m" }
.Select(extension => copy + extension).FirstOrDefault(File.Exists);
if (signature is null && File.Exists(copy))
{
var digests = new DocumentDigests(File.ReadAllBytes(copy));
var directory = Path.GetDirectoryName(Path.GetFullPath(copy))!;
foreach (var candidate in Directory.EnumerateFiles(directory).Where(SignatureDiscovery.IsSignatureFile))
{
var report = SignatureVerifier.VerifyWithDigests(File.ReadAllBytes(candidate), digests,
VerificationOptions.CryptographyOnly);
// Не затираем и при неопределённой криптопроверке, если хеш документа совпал.
if (!report.Signers.Any(s => s.Document.State == VerificationState.Valid
&& s.Signature.State != VerificationState.Invalid))
continue;
signature = candidate;
break;
}
}
if (signature is null)
return;
var name = Path.GetFileName(copy);
throw new InvalidOperationException(
$"Копия «{name}» уже подписана («{name}.sig»), новая копия со штампом сделала бы эту подпись " +
$"Копия «{name}» уже подписана («{Path.GetFileName(signature)}»), новая копия со штампом сделала бы эту подпись " +
$"недействительной. Чтобы добавить подпись к копии, подпишите сам файл «{name}» без штампа; " +
"чтобы сделать копию заново, удалите или переименуйте её файл подписи.");
}
Expand Down
3 changes: 2 additions & 1 deletion src/SignService/Services/PdfStamper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ public static string CreateStampedCopy(string pdfPath, IReadOnlyList<StampSigner
// Сначала в память, затем атомарно: сбой записи не оставит обрезанный PDF.
using var buffer = new MemoryStream();
document.Save(buffer, closeStream: false);
AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true);
AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true,
beforeCommit: () => DocumentStamper.ThrowIfCopySigned(pdfPath));
return outputPath;
}

Expand Down
3 changes: 2 additions & 1 deletion src/SignService/Services/WordStamper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ public static string CreateStampedCopy(
}
}

AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true);
AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true,
beforeCommit: () => DocumentStamper.ThrowIfCopySigned(docPath));
return outputPath;
}

Expand Down
2 changes: 1 addition & 1 deletion src/SignService/SignService.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyTitle>SignService — подписание документов ЭЦП</AssemblyTitle>
<Product>SignService</Product>
<Version>1.4.0</Version>
<Version>1.4.1</Version>
<Authors>Зайнуллин Марат Илгамович</Authors>
<Company>Зайнуллин Марат Илгамович</Company>
<Copyright>© 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com</Copyright>
Expand Down
1 change: 1 addition & 0 deletions tests/SignService.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@ await File.WriteAllTextAsync(expiredXmlPath,
await ReviewRegressionTests.RunAsync(tempRoot, signer);
await CoSignTests.RunAsync(tempRoot, signer);
await WordStampTests.RunAsync(tempRoot, signer);
await PublicationSafetyTests.RunAsync(tempRoot, signer);
UiSmokeTests.Run(tempRoot);
try { Directory.Delete(tempRoot, true); } catch { }
Console.WriteLine("ALL TESTS PASSED");
Expand Down
Loading
Loading