技術記事

Delphi の HotXLS で Excel 電子署名を検証し再署名

HotXLS は、Excel をインストールせずに Delphi で Excel 電子署名を検証し、再署名します。lxSignatureOpcIntegrity ユニットは、ブックのバイトから直接、ECMA-376 Part 2 のパッケージ署名を検査します。正規化された SignedInfo 上の RSA-SHA256、すべての SHA-256 Reference ダイジェスト、各署名が選択する正確なパーツです。証明書チェーンのトラストは独立した明示的なステップで、編集や再署名は再検証を通る出力だけを公開します

いつもの引き金は、デスクトップを超えて育った文書ワークフローです。管理者が Excel で予算ブックに署名し、Delphi のサービスが後で数字をインポートする前に署名を確認し、3 番目のステップが 1 つのシートを修正してサービス証明書で再署名する。そのためにサーバーで Excel を自動化するのは壊れやすくサポート外ですし、署名を yes/no フラグとして扱うと、実際に重要な 2 つの問いが隠れます。署名者はどのバイトにコミットしたのか、そしてその鍵を握る人を信頼するのか

OPC パッケージ署名は実際に何をカバーするのか

OPC パッケージ署名がカバーするのは、その Manifest が列挙するパーツだけで、XLSX ファイル全体ではありません。ECMA-376 Part 2 の §13(Digital Signatures)は、パッケージに署名起点パーツを置きます。たいてい /_xmlsignatures/origin.sigs で、/_xmlsignatures/sig1.xml のような 1 つ以上の署名パーツへのリレーションシップを伴います。各署名パーツは W3C XML-DSig の Signature で、その SignedInfo は内部の idPackageObject を参照し、その Object は個々のパッケージパーツへの Reference の Manifest を運び、それぞれが独自のダイジェストを持ちます

HotXLS は Reference がパーツを束ねる 3 つのやり方に対応しますが、それらは互換ではありません。生の Reference は保存されたパーツバイトをハッシュします。正規化 XML Reference は Canonical XML(コメントなし)形をハッシュするため、属性順と名前空間宣言の綴りはもう問題になりません。リレーションシップ Reference はまず OPC RelationshipTransform を適用してから正規化し、その transform は SourceId か SourceType のセレクタに一致したリレーションシップだけを保持します。セレクタが認証するのは、それが選んだリレーションシップだけです。その .rels パーツの残りは、署名が有効なまま自由に変われます

Delphi における HotXLS OPC 電子署名の構造。origin.sigs パーツから XML-DSig の署名パーツへ至り、その Manifest が各パッケージパーツを raw、canonical XML、RelationshipTransform のいずれかのリファレンスで束ねる
3 つの Reference モードは互換ではありません。raw は保存バイトをハッシュし、canonical XML は属性の綴りを無視し、リレーションシップセレクタは選ばれなかった .rels エントリを自由に変えられるままにします

この最後の点が、HotXLS が単一の緑のチェックではなくカバレッジを報告する理由です。TXLSSignatureOpcIntegrityResult.Coverage は TXLSSignatureOpcCoverage レコードの配列で、認証された Manifest Reference ごとに 1 つ、それぞれ ReferenceUri、PartName、ContentType、Transforms、RelationshipSourceIds、RelationshipSourceTypes を持ちます。インポートが sheet3.xml を読むのに、どの記述子もそれを名指ししないなら、署名は、これからロードしようとしている数字について何も語っていません

不変の ZIP スナップショットから署名を検証する

HotXLS はパッケージを 1 つの不変なバイトスナップショットから検証し、ディスク上のファイルからでも生きている 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);  // 1 つのスナップショットを検証してパース
  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 層は信頼できない XLSX ファイルへの ZIP EOCD 検証の記事で述べたのと同じ fail-closed 読みを適用し、パーツの探索はXLSX OPC リレーションシップ解決の規則に従います

署名済みブックの編集が署名を無効化する理由

署名済みブックの編集が署名を無効化するのは、変わったバイトが 1 つでも署名済みの選択の中に落ちた瞬間です。その Reference の保存された SHA-256 ダイジェストがもう一致しないからです。ブックをロードして再保存するスプレッドシートライターは、ほとんど必ず署名済みパーツに触れます。XML を再シリアライズし、共有文字列を再番号付けし、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 は 2 つの層を意図的に分けています。すべての TXLSSignatureOpcIntegrityResult は成功した検査を含めて Trust = xsstUntrusted を運び、ClaimedSignatureTime は信頼されたタイムスタンプではなく署名者提供のデータです。自己署名証明書を生成し、ブックに署名し、整合性検査を通すのは誰にでもできます

HotXLS は Delphi で Excel OPC パッケージの電子署名を 2 層で検証する。不変のバイトスナップショット上の整合性ダイジェスト検査が常に xsstUntrusted を報告し、次に呼び出し元所有のポリシーで XlsVerifySignatureOpcPackageWithTrust が走る
検証された署名が証明するのは選ばれたパーツが無傷ということです。トラストには、xsotTrustedSelection が意味を持つ前に、自分のアンカー、UTC の検証時刻、文書署名 EKU が要ります

トラストは、呼び出し元所有のポリシーを持つ別の呼び出しです。XlsDefaultSignatureOpcTrustPolicy は、アンカーなし、検証時刻なし、文書署名 EKU 1.3.6.1.4.1.311.10.3.12、xsrvRequireCached 失効の fail-closed ポリシーを返します。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 を自分で呼ぶときに見落としやすい罠が 2 つあります。CertVerifyCertificateChainPolicy の True 戻り値が意味するのは呼び出しが実行されたことだけです。受け入れには、ポリシーステータスのゼロ dwError、集約チェーンエラーのゼロ、リーフでの明示的な digitalSignature 鍵用途、必要な EKU がまだ要ります。HotXLS は、鍵用途のない証明書が普通の基本ポリシーを通れ得ることを発見したため、DigitalSignatureUsage と RequiredEkuPresent を自前で検査します。2 つ目の罠は、チェーンエンジン設定で排他的ルートストアと hRestrictedOther を組み合わせると、現行バックエンドでは E_INVALIDARG が返ることです。そこで HotXLS は排他的な呼び出し元ルートストアを使い、返された一次チェーンの全要素を、渡した DER バイトに対して認可します。たまたまローカルシステムストアに座っているだけの発行者が信頼されることはありません

xsotTrustedSelection が記述するのは、その署名とその報告カバレッジだけです。署名されていないセルと選ばれなかったリレーションシップは、選ばれた署名が信頼されたまま変われます。それゆえトラストの結果は、監査ログのために PackageSha256 と CertificateSha256 も返します

Excel なしでブックを再署名・副署名する方法

バイト配列レベルで、HotXLS は明示的に認可されたトランザクションを通じて再署名し、Workbook の署名付き保存経路は同じ検証済みパイプラインを再利用します。XlsReplaceVerifiedSignatureOpcPackage は 1 つの検証済み署名を新しいものと交換し、XlsEditAndReplaceSelectedSignatureOpcPackage はパーツ編集と 1 つの選択署名の置換を同じトランザクションで適用し、XlsAppendVerifiedSignatureOpcPackage は検証済みの元バイトをすべてバイト単位で保ったまま署名を追加します。replace 呼び出しは TXLSSignatureOpcReplaceAuthorization を取り、append 呼び出しはすべての元をまず検証し、どれも TXLSSignatureOpcIssueOptions の選択と署名者コールバックを取ります。ライブラリが秘密鍵に触れることはありません

認可レコードは操作を、承認した正確な元に束ねます。ExpectedPackageSha256、ExpectedSignaturePart、ExpectedCertificateSha256 で、すべて旧署名が完全に検証された後でのみ比較されます。編集されたすべてのパーツは新しい選択にカバーされなければならず、選ばれていないすべての署名は、署名者が呼ばれる前に、ステージングされたパッケージに対してまだ検証を通らなければならず、そうでなければ呼び出しは xspsWouldInvalidate を返します

HotXLS は Delphi で Excel XLSX パッケージを 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、スマートカードラッパーを表し、
  // 完全なビッグエンディアンの 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;

このコードの 2 つの細部は意図的です。Output と Details は別々の変数でなければなりません。Details のフィールドを Output として渡すのは明示的に未対応で、ライブラリはパッケージの公開と証明書レポートの保持を同じストレージにできません。そして旧カバレッジは継承されません。新しい署名が認証するのは、列挙した Reference だけなので、下流の消費者が頼るパーツをもう一度決めてください

副署名は同じ append 呼び出しを使います。HotXLS 2.384.296 から、XlsAppendVerifiedSignatureOpcPackage に渡す TXLSSignatureOpcIssueOptions.References は、検証済み元署名インベントリのパーツを名指しできます。レビュワーが既存の署名パーツの上に署名したり、複数の副署名を連鎖させたり、1 つの選択に署名パーツとコンテンツパーツを混ぜたりできます。追加された各署名が記録するのは自分の直接の選択だけです。そのリリースの資格付けは、7 つの出力パッケージにわたって .NET WindowsBase パッケージングベリファイアで 222 の境界チェックと 122 の独立チェックを走らせ、正確に 1 つの新しい署名、バイト単位で同一の元署名と証明書を確認しました

知っておくべき罠と限界

最もためになったバグは、完全に合法な文字から来ました。OPC Reference URI はコンテンツタイプをクエリとして運びます。たとえば /xl/worksheets/sheet1.xml?ContentType=application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml です。初期の HotXLS 発行者はリテラルの + を %2B とパーセントエンコードしました。HotXLS 自身の RSA、C14N、Reference 検査はその結果を受け入れましたが、WindowsBase は InvalidSignature を報告しました。その綴りだけを変えた管理サンプルが InvalidSignature から Success に変わりました。発行者は今、対応するリテラルの ASCII クエリ句読点を保ちつつ XML 属性はエスケープし続けます。どんな署名者への教訓も、自分自身のベリファイアだけでなく独立のネイティブベリファイアに対してテストすることです

  • トラストバックエンドは Windows 専用です。他のターゲットは整合性成功の後、xsotTrustUnavailable を返します
  • ベリファイアは 64 MiB までのパッケージ、パーツあたり 16 MiB、1 つの origin の下で最大 32 の署名パーツを受け付けます
  • 発行は署名あたり最大 256 の Reference と 4096 の合計セレクタを受け付けます
  • 対応プロファイルは RSA-SHA256、SHA-256 ダイジェスト、コメントなし Canonical XML です。他のアルゴリズム、追加のアプリケーション Object、外部 Reference は xsoiUnsupportedProfile を返します
  • xsrvRequireCached はキャッシュのみの失効データを使います。欠けたステータスは拒否で、xsrvNotChecked は失効の保証を与えません
  • Excel も受け入れなければならないブックへの署名は、Excel が有効な XLSX を修復する理由のパッケージ規則にまだ突き当たります。重複した Override やリレーションシップ Id は、署名が検査される前にファイルを壊します

FAQ

HotXLS の整合性検査は署名者が信頼されることを意味するのか

いいえ。XlsVerifySignatureOpcPackage が証明するのは、選ばれたパーツが埋め込み証明書鍵の下でダイジェストに一致することで、常に xsstUntrusted を報告します。証明書チェーンを評価するには、自分のアンカーと UTC の検証時刻で XlsVerifySignatureOpcPackageWithTrust を使ってください

Delphi での編集後に Excel がブックの署名を無効と表示する理由

保存されたブックは署名済み XML パーツを書き直すため、少なくとも 1 つの Reference の SHA-256 ダイジェストが変わります。まず編集を試すには xsoePreserve 付きの XlsEditVerifiedOpcPackage を、検証済みトランザクションで編集と再署名を一度にやるには XlsEditAndReplaceSelectedSignatureOpcPackage を使ってください

HotXLS はスマートカードや HSM 上の鍵で署名できるか

はい。署名は呼び出し元のコールバックだからです。ライブラリは正規化された SignedInfo とその 32 バイトの SHA-256 ダイジェストを渡し、あなたのコードが RSASSA-PKCS1-v1_5 署名を返し、秘密鍵はあなたが管理するプロバイダから出ません

OPC 署名検証、トラスト評価、副署名、検証済み再署名は、Excel や COM 自動化なしで XLS、XLSX、XLSB、ODS を読み書きする Delphi と C++Builder 向けネイティブ Excel スプレッドシートコンポーネント HotXLS の一部です