Техническая статья

Проверка и переподписание цифровых подписей Excel в Delphi

HotXLS проверяет и переподписывает цифровые подписи Excel в Delphi без установленного Excel. Модуль lxSignatureOpcIntegrity проверяет подписи пакета ECMA-376 Part 2 прямо из байтов книги: RSA-SHA256 над каноническим SignedInfo, дайджест каждого SHA-256 Reference и точные части, которые выбирает каждая подпись. Доверие цепочке сертификатов — отдельный явный шаг, а редактирование или переподписание публикует только вывод, который снова проходит проверку

Обычный триггер — документный конвейер, переросший рабочий стол. Контролёр подписывает бюджетную книгу в Excel, Delphi-сервис позже обязан подтвердить подпись перед импортом чисел, а третий шаг должен поправить один лист и подписать снова сертификатом сервиса. Автоматизировать Excel на сервере ради этого хрупко и не поддерживается, а трактовка подписи как флага да/нет прячет два вопроса, которые реально важны: каким байтам обязался подписант и доверяете ли вы человеку, держащему тот ключ

Что подпись пакета OPC реально покрывает?

Подпись пакета OPC покрывает только части, перечисленные в её Manifest, а не весь XLSX-файл. ECMA-376 Part 2, §13 (Digital Signatures) кладёт в пакет часть происхождения подписи, обычно /_xmlsignatures/origin.sigs, с отношениями к одной или нескольким частям подписи вроде /_xmlsignatures/sig1.xml. Каждая часть подписи — W3C XML-DSig Signature, чей SignedInfo ссылается на внутренний idPackageObject, и тот Object несёт Manifest из References к отдельным частям пакета, каждая со своим дайджестом

HotXLS поддерживает три способа, какими Reference может привязать часть, и они не взаимозаменяемы. Сырой Reference хеширует сохранённые байты части. Канонический XML Reference хеширует форму Canonical XML (without comments), так что порядок атрибутов и написание объявлений пространств имён больше не важны. Reference отношения сперва применяет OPC RelationshipTransform и лишь затем канонизирует, и тот transform оставляет только отношения, отобранные его селекторами SourceId или SourceType. Селектор аутентифицирует ровно те отношения, которые выбирает: остальная часть того .rels может меняться свободно, пока подпись остаётся валидной

Анатомия цифровой подписи OPC в HotXLS для Delphi: часть origin.sigs ведёт к частям подписи XML-DSig, чей Manifest привязывает каждую часть пакета сырой, канонической XML или RelationshipTransform ссылкой
Три режима Reference не взаимозаменяемы: сырой хеширует сохранённые байты, канонический XML игнорирует написание атрибутов, а селекторы отношений оставляют неотобранные записи .rels свободными к изменениям

Последний пункт — причина, по которой HotXLS сообщает покрытие, а не одиночную зелёную галочку. TXLSSignatureOpcIntegrityResult.Coverage — массив записей TXLSSignatureOpcCoverage, по одной на аутентифицированный Manifest Reference, каждая с ReferenceUri, PartName, ContentType, Transforms, RelationshipSourceIds и RelationshipSourceTypes. Если ваш импорт читает sheet3.xml, а ни один дескриптор его не называет, подпись ничего не говорит о числах, которые вы собираетесь загрузить

Проверка подписей из неизменяемого ZIP-снимка

HotXLS проверяет пакет из одного неизменяемого байтового снимка, никогда не с файла на диске и не с живого TWorkbook. XlsVerifySignatureOpcPackage и его многоподписные собратья принимают массив TBytes, читают только его и никогда не забирают внешние цели, сертификаты, локальные файлы или сетевые ресурсы. Смысл в том, что байты, которые вы проверили, — это байты, которые вы потом разбираете: если файл перечитать позже, другой процесс мог тем временем его подменить

Канонизация — то место, где самодельные верификаторы обычно ошибаются. SignedInfo и пакетный Object обязаны канонизироваться в контексте пространств имён полного конверта Signature. Вырезанный из документа изолированный фрагмент наследует иные объявления пространств имён, чем видит верификатор на месте, поэтому его C14N-байты другие, дайджест другой, и совершенно валидная подпись проваливается. HotXLS использует полный контекст конверта и на проверяющей, и на выпускающей стороне

Для пакетов, могущих нести больше одной подписи, XlsVerifyAllSignaturesOpcPackage проверяет каждую приложенную подпись и сохраняет индивидуальные исходы. Агрегатный статус — xsoiIntegrityVerified, только когда проходит каждая допущенная подпись; иначе это первый сбой в порядке инвентаря, и каждый отчёт всё равно говорит, какая часть подписи провалилась

uses
  System.SysUtils, System.IOUtils, lxSignatureOpcIntegrity;

function AllSignaturesIntact(const FileName: string): Boolean;
var
  Bytes: TBytes;
  Reports: TXLSSignatureOpcVerifications;
  Status: TXLSSignatureOpcIntegrityStatus;
  I, J: Integer;
begin
  Bytes := TFile.ReadAllBytes(FileName);  // один снимок: проверен и разобран
  Status := XlsVerifyAllSignaturesOpcPackage(Bytes, Reports);
  for I := 0 to High(Reports) do
  begin
    Writeln(String(Reports[I].SignaturePart), ': ', Ord(Reports[I].Status));
    if Reports[I].Status = xsoiIntegrityVerified then
      for J := 0 to High(Reports[I].Integrity.Coverage) do
        Writeln('  covers ', String(Reports[I].Integrity.Coverage[J].PartName));
  end;
  Result := Status = xsoiIntegrityVerified;  // xsoiUnsigned — это не зачёт
end;

Значения статусов достаточно конкретны, чтобы действовать. xsoiDigestMismatch значит, что выбранная часть изменилась после подписания, xsoiInvalidSignature — что значение RSA не верифицируется против SignedInfo ключом встроенного сертификата, xsoiPartUnavailable — что выбранная часть отсутствует, а xsoiUnsupportedProfile или xsoiResourceLimit — что верификатор отказал, а не угадывал. Слой ZIP под ними применяет тот же fail-closed читатель, описанный в статье о валидации ZIP EOCD для недоверенных XLSX-файлов, а поиск частей следует правилам из статьи о разрешении отношений OPC в XLSX

Почему правка подписанной книги инвалидирует подпись?

Правка подписанной книги инвалидирует подпись, как только изменённый байт попадает внутрь подписанной выборки, потому что сохранённый дайджест SHA-256 того Reference больше не совпадает. Табличный писатель, который загружает книгу и сохраняет её снова, почти всегда трогает подписанные части: он заново сериализует XML, перенумеровывает shared strings, переписывает workbook.xml и манифест типов содержимого. Даже когда значения ячеек идентичны, байты — нет, и следующая проверка возвращает xsoiDigestMismatch

HotXLS позволяет задать вопрос прежде, чем связываться обязательствами правки. XlsEditVerifiedOpcPackage заменяет существующие части в проверенном пакете, а затем верифицирует кандидата. С xsoePreserve он возвращает xsoePreserved, только когда подпись всё ещё проходит проверку — что случается, когда правленные части лежат вне всяких подписанных выборок, — и xsoeWouldInvalidate в противном случае. С xsoeInvalidateChanged он убирает привязку подписи из корневых отношений и [Content_Types].xml и возвращает явно неподписанный пакет как xsoeInvalidated, вместо того чтобы всучить вам файл, который всё ещё выглядит подписанным

function EditKeepsSignature(const Signed, NewSheetXml: TBytes;
  out Edited: TBytes): Boolean;
var
  Edits: TXLSSignatureOpcEdits;
  Details: TXLSSignatureOpcEditResult;
begin
  SetLength(Edits, 1);
  Edits[0].PartName := '/xl/worksheets/sheet2.xml';
  Edits[0].Data := NewSheetXml;
  case XlsEditVerifiedOpcPackage(Signed, Edits, xsoePreserve, Edited, Details) of
    xsoePreserved:       Result := True;   // sheet2 была вне подписанной выборки
    xsoeWouldInvalidate: Result := False;  // подписанный дайджест покрывает sheet2
  else
    raise Exception.CreateFmt('Edit refused, original integrity %d',
      [Ord(Details.OriginalIntegrity)]);
  end;
end;

Целостность против доверия: что доказывает проверенная подпись?

Проверенная подпись доказывает, что выбранные части не менялись с тех пор, как их подписал некто, держащий приватный ключ, — и ничего о том, кто этот некто. HotXLS нарочно держит два слоя порознь: каждый TXLSSignatureOpcIntegrityResult несёт Trust = xsstUntrusted, включая успешные проверки, а ClaimedSignatureTime — данные, заявленные подписантом, а не доверенная метка времени. Кто угодно может сгенерировать самоподписанный сертификат, подписать книгу и пройти проверку целостности

HotXLS проверяет цифровые подписи пакета OPC Excel в Delphi в два слоя: проверки дайджестов целостности над неизменяемым байтовым снимком, всегда сообщающие xsstUntrusted, затем XlsVerifySignatureOpcPackageWithTrust с политикой, принадлежащей вызывающему
Проверенная подпись доказывает неизменность выбранных частей, тогда как доверие требует собственных якорей, времени проверки в UTC и EKU подписания документов, прежде чем xsotTrustedSelection что-либо значит

Доверие — отдельный вызов с политикой, принадлежащей вызывающему. XlsDefaultSignatureOpcTrustPolicy возвращает fail-closed политику без якорей, без времени проверки, с EKU подписания документов 1.3.6.1.4.1.311.10.3.12 и отзывом xsrvRequireCached. XlsVerifySignatureOpcPackageWithTrust сперва гонит полный верификатор целостности, а затем оценивает сертификат, который его реально прошёл; вызывающий не может подставить другой листовой сертификат

uses
  System.SysUtils, System.DateUtils, lxSignatureOpcIntegrity;

function SignedUnderCorporateCA(const Bytes, RootDer, IssuingCaDer: TBytes): Boolean;
var
  Policy: TXLSSignatureOpcTrustPolicy;
  Trust: TXLSSignatureOpcTrustResult;
begin
  Policy := XlsDefaultSignatureOpcTrustPolicy;
  SetLength(Policy.TrustAnchors, 1);
  Policy.TrustAnchors[0] := RootDer;
  SetLength(Policy.Intermediates, 1);
  Policy.Intermediates[0] := IssuingCaDer;
  Policy.VerificationTimeUtc := TTimeZone.Local.ToUniversalTime(Now);

  Result := XlsVerifySignatureOpcPackageWithTrust(Bytes, Policy, Trust)
    = xsotTrustedSelection;
  if not Result then
    Writeln(Format('trust %d, integrity %d, policy error %.8x, chain errors %.8x',
      [Ord(Trust.Status), Ord(Trust.IntegrityStatus),
       Trust.NativePolicyError, Trust.NativeChainErrors]));
end;

У бэкенда Windows две ловушки, которые легко пропустить, вызывая WinCrypt самому. CertVerifyCertificateChainPolicy, вернувшая True, означает лишь, что вызов выполнился; для принятия всё ещё нужны нулевой dwError в статусе политики, нулевые агрегированные ошибки цепочки, явное использование ключа digitalSignature на листовом сертификате и требуемый EKU. HotXLS обнаружил, что сертификат без key usage может пройти обычную базовую политику, поэтому он проверяет DigitalSignatureUsage и RequiredEkuPresent сам. Вторая ловушка: комбинация эксклюзивного хранилища корней с hRestrictedOther в конфиге движка цепочки возвращает E_INVALIDARG на текущем бэкенде, поэтому HotXLS использует эксклюзивное хранилище корней вызывающего, а затем авторизует каждый элемент возвращённой первичной цепочки против переданных вами DER-байтов. Издатель, просто случайно сидящий в локальном системном хранилище, доверенным не становится

xsotTrustedSelection по-прежнему описывает только ту подпись и её сообщённое покрытие. Неподписанные ячейки и неотобранные отношения могут меняться, пока выбранная подпись остаётся доверенной, поэтому результат доверия возвращает ещё PackageSha256 и CertificateSha256 для вашего журнала аудита

Как переподписать или встречно подписать книгу без Excel?

На уровне массивов байтов HotXLS переподписывает через явные, авторизованные транзакции, а подписывающие пути сохранения Workbook переиспользуют те же проверенные конвейеры. XlsReplaceVerifiedSignatureOpcPackage меняет одну проверенную подпись на новую, XlsEditAndReplaceSelectedSignatureOpcPackage применяет правки частей и заменяет одну выбранную подпись в той же транзакции, а XlsAppendVerifiedSignatureOpcPackage добавляет подпись, сохраняя каждый проверенный оригинал байт в байт. Вызовы замены принимают TXLSSignatureOpcReplaceAuthorization, вызов добавления сперва верифицирует каждый оригинал, и все они принимают выборку TXLSSignatureOpcIssueOptions и колбэк подписанта; библиотека никогда не трогает ваш приватный ключ

Запись авторизации привязывает операцию к точному оригиналу, который вы одобрили: ExpectedPackageSha256, ExpectedSignaturePart и ExpectedCertificateSha256, сверяемые только после того, как старая подпись полностью верифицировалась. Каждая правленная часть обязана покрываться новой выборкой, каждая неотобранная подпись обязана по-прежнему верифицироваться против поставленного на подготовку пакета до вызова подписанта, иначе вызов возвращает xspsWouldInvalidate

HotXLS переподписывает пакет XLSX Excel в Delphi через XlsEditAndReplaceSelectedSignatureOpcPackage, который верифицирует старую подпись, сверяет ожидаемые хеши пакета, части и сертификата и вызывает колбэк подписанта
Запись авторизации привязывает замену к одобренному оригиналу, неотобранные подписи обязаны по-прежнему верифицироваться на подготовленном пакете, а приватный ключ остаётся внутри вашего CNG, HSM или смарт-карты
uses
  System.SysUtils, System.DateUtils, lxHash, lxSignatureOpcIntegrity;

type
  TWorkbookResigner = class
  private
    function SignDigest(const Request: TXLSSignatureOpcSelectedReplaceRequest;
      var Signature: TBytes): TXLSSignatureOpcSignerStatus;
  public
    function Resign(const Approved, NewSheetXml, NewCertDer: TBytes;
      const OldCertSha256: WideString; var Output: TBytes): Boolean;
  end;

function TWorkbookResigner.SignDigest(
  const Request: TXLSSignatureOpcSelectedReplaceRequest;
  var Signature: TBytes): TXLSSignatureOpcSignerStatus;
begin
  // Request.NewSignature.Digest — вычисленная библиотекой 32-байтная SHA-256;
  // RsaSignSha256Pkcs1 олицетворяет вашу собственную обёртку CNG, HSM или
  // смарт-карты и обязана вернуть полную big-endian подпись RSASSA-PKCS1-v1_5
  if RsaSignSha256Pkcs1(Request.NewSignature.Digest, Signature) then
    Result := xsosSigned
  else
    Result := xsosFailed;
end;

function TWorkbookResigner.Resign(const Approved, NewSheetXml, NewCertDer: TBytes;
  const OldCertSha256: WideString; var Output: TBytes): Boolean;
var
  Auth: TXLSSignatureOpcReplaceAuthorization;
  Options: TXLSSignatureOpcIssueOptions;
  Edits: TXLSSignatureOpcEdits;
  Details: TXLSSignatureOpcSelectedReplaceResult;
begin
  Auth.ExpectedPackageSha256 := XLSComputeSHA256Hex(Approved); // или хеш, записанный при одобрении
  Auth.ExpectedSignaturePart := '/_xmlsignatures/sig1.xml';
  Auth.ExpectedCertificateSha256 := OldCertSha256;

  SetLength(Edits, 1);
  Edits[0].PartName := '/xl/worksheets/sheet1.xml';
  Edits[0].Data := NewSheetXml;

  SetLength(Options.References, 2);
  Options.References[0].PartName := '/xl/workbook.xml';
  Options.References[0].Mode := xsimCanonicalXml;
  Options.References[1].PartName := '/xl/worksheets/sheet1.xml';
  Options.References[1].Mode := xsimCanonicalXml;
  Options.ClaimedSignatureTimeUtc := FormatDateTime('yyyy"-"mm"-"dd"T"hh":"nn":"ss"Z"',
    TTimeZone.Local.ToUniversalTime(Now));

  Result := XlsEditAndReplaceSelectedSignatureOpcPackage(Approved, NewCertDer,
    Auth, Options, Edits, SignDigest, Output, Details) = xspsReplaced;
end;

Две детали в том коде намеренные. Output и Details обязаны быть отдельными переменными; передача поля Details как Output явно не поддерживается, потому что библиотека не может опубликовать пакет и держать отчёт о сертификате в одном хранилище. И старое покрытие не наследуется: новая подпись аутентифицирует ровно те References, что вы перечислили, так что решите заново, на какие части будет опираться нижестоящий потребитель

Встречное подписание использует тот же вызов добавления. С HotXLS 2.384.296 TXLSSignatureOpcIssueOptions.References, передаваемые в XlsAppendVerifiedSignatureOpcPackage, могут называть части из инвентаря проверенного оригинала подписи, поэтому проверяющий может подписаться поверх существующей части подписи, выстроить цепочку из нескольких встречных подписей или смешать части подписей и части содержимого в одной выборке. Каждая добавленная подпись записывает только свои прямые выборки. Квалификация того релиза прогнала 222 граничные проверки и 122 независимые проверки с упаковочным верификатором .NET WindowsBase по 7 выходным пакетам, подтвердив ровно одну новую подпись и побайтово идентичные оригинальные подписи и сертификаты

Грабли и пределы, о которых стоит знать

Самый поучительный баг пришёл от символа, который совершенно легален. URI Reference OPC несут тип содержимого как query, например /xl/worksheets/sheet1.xml?ContentType=application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml. Ранний издатель HotXLS процент-кодировал литерал + как %2B. Собственные проверки RSA, C14N и Reference в HotXLS приняли результат, но WindowsBase сообщил InvalidSignature; контролируемый сэмпл, менявший только то написание, перескочил из InvalidSignature в Success. Издатель теперь сохраняет поддерживаемую литеральную ASCII-пунктуацию query, продолжая экранировать XML-атрибуты, а урок для любого подписанта — тестироваться против независимого нативного верификатора, а не только против своего

  • Бэкенд доверия — только Windows; прочие цели возвращают xsotTrustUnavailable после успешной целостности
  • Верификатор принимает пакеты до 64 МиБ, по 16 МиБ на часть, и максимум 32 части подписи под одним origin
  • Выпуск принимает до 256 References и 4096 суммарных селекторов на подпись
  • Поддерживаемый профиль — RSA-SHA256, дайджесты SHA-256 и Canonical XML без комментариев; прочие алгоритмы, лишние Objects приложений и внешние References возвращают xsoiUnsupportedProfile
  • xsrvRequireCached использует только кешированные данные отзывов; отсутствующий статус отвергается, а xsrvNotChecked не даёт никаких гарантий отзыва
  • Подписание книги, которую обязан принять ещё и Excel, упирается в правила пакета из статьи о том, почему Excel чинит валидный XLSX; дублированный Override или Id отношения ломает файл раньше всякой проверки подписи

FAQ

Означает ли проверка целостности HotXLS, что подписант доверенный?

Нет. XlsVerifySignatureOpcPackage доказывает, что выбранные части совпадают со своими дайджестами под ключом встроенного сертификата, и всегда сообщает xsstUntrusted. Используйте XlsVerifySignatureOpcPackageWithTrust со своими якорями и временем проверки в UTC, чтобы оценить цепочку сертификатов

Почему Excel показывает подпись моей книги как невалидную после правки в Delphi?

Сохранённая книга переписывает подписанные XML-части, поэтому дайджест SHA-256 минимум одного Reference меняется. Используйте XlsEditVerifiedOpcPackage с xsoePreserve, чтобы сперва протестировать правку, или XlsEditAndReplaceSelectedSignatureOpcPackage, чтобы править и переподписывать в одной проверенной транзакции

Может ли HotXLS подписывать ключом на смарт-карте или HSM?

Да, потому что подписание — колбэк вызывающего. Библиотека передаёт канонический SignedInfo и его 32-байтный дайджест SHA-256, ваш код возвращает подпись RSASSA-PKCS1-v1_5, а приватный ключ никогда не покидает провайдер, которым вы управляете

Проверка подписей OPC, оценка доверия, встречное подписание и проверенное переподписание — часть HotXLS, нативного Excel spreadsheet component для Delphi и C++Builder, читающего и пишущего XLS, XLSX, XLSB и ODS без Excel и без COM-автоматизации