Bài viết kỹ thuật

Ký PDF từ Cert Store trong HotPDF: Thứ tự byte CNG so với CAPI

HotPDF ký một PDF bằng chứng chỉ đã có sẵn trong Windows Certificate Store bằng cách chuyển digest cho chính Windows xử lý, và Windows hoàn tất yêu cầu đó qua một trong hai backend khóa riêng: CNG, trả về chữ ký RSA theo thứ tự big-endian, hoặc CryptoAPI CSP kiểu cũ, trả về theo thứ tự little-endian. Nhầm lẫn giữa hai thứ tự này khiến chữ ký CMS mà HotPDF nhúng vào bị đảo ngược byte tùy theo backend nào thực sự trả lời, nên một trình kiểm định tuân thủ chuẩn sẽ báo chữ ký không hợp lệ dù byte của tài liệu chưa hề bị đụng tới

Đằng sau một câu đó là hai vấn đề không liên quan với nhau, và bộ ký chứng chỉ hệ thống của HotPDF phải giải quyết cả hai trước khi có thể ký bất cứ thứ gì. Lỗi lệch thứ tự byte diễn ra âm thầm: lệnh ký vẫn trả về True, PDF vẫn mở được, và lỗi chỉ lộ ra khi một trình xem PDF duyệt qua cấu trúc CMS rồi từ chối nó. Vấn đề thứ hai thì lộ liễu và chỉ xảy ra với C++Builder: sáu hàm crypt32 từ chối link, vì thư viện import mà RAD Studio đi kèm không export chúng. Không vấn đề nào trong hai vấn đề này xuất hiện nếu bạn chỉ ký bằng file PFX, đó là lý do nó thường "bẫy" các lập trình viên chuyển từ ký một lệnh dựa trên PFX sang chứng chỉ mà bộ phận IT đã cài sẵn trong profile người dùng

Chọn chứng chỉ từ store

HotPDF phơi bày đường dẫn này qua HPDFSignPDFStreamWithSystemCertificateHPDFSignPDFFileWithSystemCertificate, cả hai đều được điều khiển bởi một bản ghi THPDFCertificateStoreSelector: Location (cslCurrentUser hoặc cslLocalMachine), StoreName (mặc định là 'MY', store cá nhân), Thumbprint SHA-1, và cờ AllowUI. Thumbprint được chuẩn hóa nội bộ, nên dấu gạch nối hay khoảng trắng copy trực tiếp từ giao diện Certificate Manager sẽ bị loại bỏ trước khi so sánh

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False quan trọng hơn vẻ ngoài của nó, vì cờ này ánh xạ trực tiếp vào CRYPT_ACQUIRE_SILENT_FLAG, và Windows tuân thủ điều đó một cách nghiêm ngặt: nếu khóa riêng của chứng chỉ khớp nằm trên smart card hoặc token cần nhập PIN mà Windows chưa cache sẵn, CryptAcquireCertificatePrivateKey sẽ thất bại thay vì bật ra một hộp thoại từ một tiến trình có thể là service. Lỗi đó lộ liễu, một EHPDFCMSError mà bạn thấy ngay lập tức, nhưng dễ bị hiểu nhầm là "không tìm thấy chứng chỉ" trong khi nguyên nhân thực sự là một token đang chờ một PIN mà chẳng ai gõ vào

Vì sao CNG và CAPI bất đồng về thứ tự byte?

Backend nào trả lời không phải là chuyện đoán mò: CryptAcquireCertificatePrivateKey báo cáo điều đó trực tiếp qua tham số ra KeySpec, và chính giá trị đó là thứ mà bộ ký của HotPDF dùng để rẽ nhánh. Một khóa từ CNG Key Storage Provider trả về với KeySpec mang giá trị đặc biệt CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); bất cứ giá trị nào khác là khóa CryptoAPI CSP truyền thống. Phần lớn chứng chỉ cá nhân được cấp hoặc import trên một bản Windows hiện đại đều phân giải về CNG dù lớp CSP kiểu cũ vẫn tồn tại để tương thích ngược, đó là lý do HotPDF yêu cầu CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG cùng với CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG trước khi xem giá trị nào được trả về

Hai backend này không chỉ gọi các hàm khác nhau, NCryptSignHash với khóa CNG, CryptSignHashA với khóa CSP; chúng còn trả về chữ ký RSA thô theo thứ tự byte ngược nhau. Đầu ra của CNG đã khớp với những gì PKCS#1 mong đợi: một chuỗi octet big-endian, byte có trọng số lớn nhất đứng trước, đúng như những gì phép chuyển đổi I2OSP của RFC 8017 tạo ra và những gì trường chữ ký của một CMS SignerInfo (RFC 5652) cần theo ISO 32000-1 §12.8.3. Ngược lại, CryptSignHash của CryptoAPI trả về chữ ký theo thứ tự little-endian, một đặc điểm được ghi nhận từ cách các CSP cổ điển biểu diễn số lớn nội bộ. Bỏ qua bước đảo byte trên đường đi CAPI thì mọi byte trong chữ ký sẽ nằm sai vị trí; phép toán RSA vẫn đúng, nhưng chuỗi octet mà trình kiểm định đọc được lại không phải chuỗi mà PKCS#1 định nghĩa

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

Còn callback ký tùy chỉnh thì sao?

Bất kỳ ai bỏ qua bộ ký chứng chỉ tích hợp sẵn của HotPDF đều thừa hưởng cùng quy tắc thứ tự byte đó. HPDFCMSSignPDFStreamWithExternalSigner nhận một THPDFCMSSignDigestCallback, một closure kiểu reference to function(const SignedAttributesSHA256: TBytes): TBytes, dùng để ký thông qua HSM, một stack middleware smart card, hoặc bất cứ thứ gì không phải chứng chỉ mà Windows store có thể trao cho bạn một handle khóa. Dù backend đứng sau callback đó là gì, các byte nó trả về đều phải nằm ở thứ tự big-endian trước khi HotPDF gấp chúng vào cấu trúc CMS

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

Cần nói rõ một ranh giới ở đây: cả hai đường ký tích hợp sẵn của HotPDF, CNG qua NCryptSignHash với đệm PKCS#1, và CAPI qua CryptSignHashA, đều nhắm tới khóa RSA ký một digest SHA-256 32 byte. Không đường nào thương lượng định dạng chữ ký ECDSA. Một chứng chỉ có khóa riêng dựa trên EC cần một bộ ký do chính bạn viết dựa trên HPDFCMSSignPDFStreamWithExternalSigner, mã hóa chữ ký ECDSA theo đúng cách CMS mong đợi thay vì giả định một chuỗi byte RSA độ dài cố định, vì vậy đừng kỳ vọng bộ ký chứng chỉ tích hợp sẵn sẽ tự động làm đúng với một token được cấp chứng chỉ EC

Vì sao C++Builder không link được CertOpenStore?

Vì thư viện import C++Builder mặc định của RAD Studio, import32.lib, không export CertOpenStore, cũng như năm hàm lân cận: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, và CryptAcquireCertificatePrivateKey. Các bản build Delphi không bao giờ gặp vấn đề này, vì dcc32/dcc64 phân giải một import external 'crypt32.dll' tĩnh trực tiếp vào bảng import của file PE. C++Builder thì khác: trình biên dịch Delphi phát sinh một file .obj dạng OMF cho bản build package, ilink32 link nó, và tại thời điểm đó cùng khai báo external ấy chỉ là một ký hiệu chưa phân giải, đang chờ một thư viện import trên dòng lệnh. Trỏ linker vào thư mục psdk của Windows SDK, nơi crypt32.lib đầy đủ export cả sáu ký hiệu, cũng không khắc phục được vấn đề: ilink32 chỉ link các thư viện import thực sự được nêu tên trên dòng lệnh của nó, mặc định là import32.lib cp32mt.lib, và việc thêm một đường dẫn tìm kiếm không khiến nó tự kéo thêm bất cứ thứ gì từ đường dẫn đó. Chạy tdump trên import32.lib xác nhận trực tiếp khoảng trống này, không có kết quả nào khớp cho CertOpenStore, trong khi crypt32.lib của SDK cho ra sáu kết quả khớp sạch sẽ

HotPDF giải quyết vấn đề này theo đúng cách nó đã xử lý việc liệt kê chứng chỉ ở nơi khác trong thư viện: thay vì yêu cầu linker cung cấp các ký hiệu này, nó nạp chúng tại thời điểm chạy. Một bản ghi nội bộ THPDFCryptoProcs mang theo một handle crypt32.dll, một handle advapi32.dll, và mười một trường con trỏ hàm; LoadCryptoProcs nạp cả hai DLL và phân giải từng entry point bằng GetProcAddress đúng một lần, ngay tại đầu HPDFSignPDFStreamWithSystemCertificate, và ném ra EHPDFCMSError ngay lập tức nếu thiếu bất cứ thứ gì, thay vì để lỗi xảy ra muộn hơn dưới dạng access violation nằm sâu trong luồng ký

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

Việc nạp chỉ diễn ra một lần cho mỗi lệnh gọi thay vì nạp lười bên trong từng hàm hỗ trợ, vì closure lựa chọn giữa CNG và CAPI capture bảng hàm đã nạp theo giá trị và phải tồn tại suốt toàn bộ luồng ký, kể cả callback vào HPDFCMSSignPDFStreamWithExternalSigner; cả hai handle DLL được giải phóng trong khối finally ngoài cùng khi việc ký hoàn tất hoặc phát sinh lỗi. Không điều gì trong số này đụng vào bề mặt công khai: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, và THPDFCertificateStoreSelector vẫn giữ nguyên chữ ký hàm như trước, nên để nhận bản vá này chỉ cần rebuild lại đối với các bên gọi hiện có, không cần đổi code

Những gì bài viết này không bao quát

Làm đúng thứ tự byte và sửa xong link C++Builder tạo ra một CMS SignerInfo mà trình kiểm định có thể phân tích và một chữ ký mà nó có thể kiểm tra về mặt số học; điều đó không nói lên gì về việc trình kiểm định đó có nên tin tưởng chứng chỉ đứng sau hay không, vì việc dựng chain, kiểm tra thu hồi, và chính sách timestamp là những mối quan tâm riêng biệt được xếp chồng lên trên qua các tùy chọn CMS, không phải thứ mà việc đúng thứ tự byte tự động mang lại. Có hai chi tiết dọn dẹp quan trọng không kém phần mật mã học: PCCERT_CONTEXT trả về từ việc tra cứu chứng chỉ phải được giải phóng bằng CertFreeCertificateContext trước khi store đóng lại, và một handle khóa CNG hoặc CSP đã lấy được, khi API báo rằng caller sở hữu nó, phải được giải phóng qua đúng lệnh gọi của backend tương ứng, không bao giờ dùng lệnh của backend kia. Nếu kết quả svValid bạn nhận được sau tất cả những điều này hóa ra hẹp hơn bạn tưởng, bài viết về xác minh chữ ký số PDF trình bày chính xác cờ đó hứa hẹn và không hứa hẹn điều gì. Vì chứng chỉ luôn nằm trong sự quản lý của Windows suốt quá trình này, việc ký từ cert store né tránh được cả một bề mặt tấn công: không có file PKCS#12 nào cần phân tích và không có ASN.1 nào bạn phải tự duyệt qua, đó chính là vấn đề mà việc gia cố PKCS#12 và ASN.1 của HotPDF giải quyết cho đường ký bằng file PFX

Ký từ cert store, ký PFX, và callback ký ngoài là ba cánh cửa dẫn vào cùng một pipeline CMS/PKCS#7 bên trong thành phần PDF HotPDF dành cho Delphi và C++Builder, và việc chọn đúng cánh cửa chủ yếu phụ thuộc vào ai được phép giữ khóa riêng: tiến trình của bạn, một file PFX, hay chính Windows