Một workbench nối kiểm tra tuân thủ với ký số phải điều phối bốn bước, theo đúng thứ tự này, và giữ chúng gắn chặt với một tập byte duy nhất suốt cả chặng đường. Nó chạy preflight PDF/A hoặc PDF/UA. Nó áp dụng mọi sửa chữa mà các phát hiện đòi hỏi rồi lưu ra một revision đã chỉnh. Nó ký đúng revision đó. Sau đó nó đọc lại tệp đã ký và xác nhận chữ ký thực sự bao phủ tệp. Thứ tự này không phải để trang trí. Bỏ qua bước đọc lại nghĩa là bạn đang tin vào chính đường ghi của mình; để preflight chạy trên sai revision thì báo cáo tuân thủ của bạn mô tả một tệp mà bạn chưa bao giờ phát hành
Phần mà hầu hết pipeline tự dựng làm sai chính là đường nối giữa kiểm tra và ký. Chạy chúng như hai công cụ tách rời với một lượt khắc phục ở giữa thì ít nhất ba revision khác nhau của tệp sẽ ra đời, mỗi revision mang tập byte riêng. Báo cáo preflight bạn đưa cho kiểm toán viên mô tả một trong số đó. Chữ ký đóng băng một cái khác. Không có gì trong tệp khẳng định chúng là cùng một revision, và thường thì không phải. PDF Library for Delphi, thư viện losLab PDF Developer Library dành cho Delphi và C++Builder, đặt preflight và ký PAdES sau một lớp facade duy nhất, nên toàn bộ trình tự có thể nằm trong một tiến trình không bao giờ đánh mất dấu vết của tập byte mà nó đang nói tới. Mọi lệnh gọi bên dưới đều tồn tại trong thư viện hiện tại, và mọi cái bẫy ghi kèm cũng vậy
Ba revision của một tài liệu, và khe hở mở ra thế nào
Hãy đếm số lần lưu. Bản gốc đến từ hệ thống đầu nguồn. Lượt khắc phục nạp nó lên, bật một chế độ tuân thủ, rồi ghi ra một revision đã chỉnh. Lượt ký nối thêm chữ ký dưới dạng incremental update, đó là lần ghi thứ ba. Ba lần lưu, ba bố cục byte, và một báo cáo preflight chẳng có ý nghĩa gì trừ khi nó nêu rõ nó bao phủ cái nào trong ba cái đó. Một giá trị SHA-256 của tệp, ghi lại bên cạnh mỗi lượt preflight và mỗi chữ ký, là mỏ neo rẻ tiền cho phép bạn chứng minh revision bạn đã kiểm tra chính là revision bạn đã ký
Một hành vi của thư viện siết kỷ luật đó chặt hơn nữa. Các sửa chữa tuân thủ yêu cầu qua SetPDFAMode hay SetPDFUAMode không có hiệu lực ngay lúc bạn gọi. Chúng được áp dụng trong lúc lưu. Những sửa chữa tự động như ép cờ in của annotation hay gán thứ tự tab PDF/UA chỉ đáp xuống tệp kết quả chứ không ở đâu khác, nên một lượt kiểm tra chạy trên tài liệu bạn vừa "sửa" trong bộ nhớ chẳng nói lên điều gì về tập byte sắp gửi tới bên ký. Lưu trước, rồi mới preflight tệp đã lưu. Trạng thái trong bộ nhớ là bản nháp; chỉ tệp trên đĩa mới là thật
Preflight từ đĩa, và con số không mang hai nghĩa
Điểm vào preflight ở tầng phẳng là CheckFileCompliance(FileName, Password, ComplianceTest, Options). Test 1 chọn PDF/A (ISO 19005), test 2 chọn PDF/UA (ISO 14289). Nó mở tệp qua bộ đọc streaming của thư viện, nên không cần LoadFromFile trước, và nó trả về một handle string-list mang mỗi phát hiện trên một mục:
var
PDF: TPDFlib;
ListID, I: Integer;
begin
PDF := TPDFlib.Create;
try
ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0); // 1 = PDF/A
if ListID = 0 then
begin
if PDF.LastErrorCode <> 0 then
raise Exception.Create('Preflight could not read the file')
else
Writeln('No PDF/A findings');
end
else
begin
for I := 0 to PDF.GetStringListCount(ListID) - 1 do
Writeln(PDF.GetStringListItem(ListID, I));
PDF.ReleaseStringList(ListID);
end;
finally
PDF.Free;
end;
end;
Cái bẫy nằm ở giá trị trả về, và nó thuộc loại vượt qua mọi bài kiểm tra theo đường thuận. Số không nghĩa là "không có phát hiện nào." Số không cũng nghĩa là "không mở được tệp," vì phần cài đặt trả về 0 mỗi khi danh sách kết quả rỗng, kể cả khi lỗi đọc. Một workbench đọc số 0 như đèn xanh sẽ vui vẻ phê duyệt một tệp đang bị tiến trình khác khóa. Ghép lệnh gọi này với LastErrorCode, như ở trên, chính là điều tách bạch hai trường hợp. Bộ kiểm tra cũng mở tệp với chế độ chia sẻ cấm ghi, nên nếu bước khắc phục của bạn vẫn đang giữ một handle ghi, preflight sẽ thất bại vì lý do chẳng liên quan gì tới tuân thủ mà hoàn toàn liên quan tới một stream bạn quên giải phóng
Khi con người chứ không phải pipeline cần đọc các phát hiện, CreatePreflightReport kết xuất chúng thành một báo cáo dễ đọc. ComparePreflightReports so sánh hai lượt chạy, đây là cách gọn gàng để cho thấy việc khắc phục đã xóa các phát hiện ban đầu mà không lặng lẽ tạo thêm phát hiện mới
Ký revision đã kiểm tra bằng SignProcess
Một khi revision đã lưu vượt qua preflight và hash của nó đã được ghi nhận, hãy ký đúng tệp đó và không tệp nào khác. API SignProcess đọc như một builder. Mở một handle tiến trình, cấu hình từng dòng, commit, rồi đọc mã kết quả về
ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached'); // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2); // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192); // chừa chỗ cho timestamp về sau
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
Hai dòng trong trình tự đó nặng ký hơn vẻ ngoài của chúng. SetSignProcessCustomSubFilter với ETSI.CAdES.detached chọn chữ ký PAdES theo hồ sơ trong ETSI EN 319 142-1 thay vì họ adbe.pkcs7.detached cũ, và đó là khác biệt giữa một chữ ký được bộ thẩm định châu Âu chấp nhận với một chữ ký bị nó gắn cờ. SetSignProcessReserveContentsBytes chèn đệm cho chỗ giữ /Contents, và kích thước bạn chọn ở đây là một quyết định về tương lai: nếu sau này có thêm dấu thời gian cho chữ ký, khối CMS phình ra phải vừa vặn khoảng trống bạn dành sẵn bây giờ, bởi chỗ giữ đó không thể nới rộng về sau nếu không ký lại toàn bộ. Dành rộng tay thì bạn phí vài kilobyte. Dành quá khít thì bước timestamp sẽ hỏng vào vài tháng sau với một lỗi tràn mà bạn sẽ chật vật lần ngược về đúng dòng này
GetSignProcessResult trả lời bằng một mã chứ không phải giá trị boolean, và các mã đó đáng giữ lại. 1 là thành công. 4 là sai mật khẩu PDF, 7 là sai mật khẩu chứng thư, 9 là tệp PFX không mang khóa riêng, 11 là lỗi trong lúc áp chữ ký. Gộp tất cả thành true/false là bạn vứt đi đúng mẩu thông tin phân biệt một ca hỗ trợ sai mật khẩu với một ca khóa thiếu phần private. Hãy ghi log số nguyên đó
Đọc lại: kiểm toán chính tệp bạn vừa tạo ra
Không workbench nào nên tin vào đường ghi đã tạo ra tệp mà nó sắp chứng nhận. Lớp kiểm toán TPDFlibSignDoc mở lại tệp đã ký và đọc thẳng các mục của signature dictionary từ đĩa:
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// Lấy kích thước trước khi Open: đối tượng kiểm toán giữ share lock trên tệp
FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
SourceSize := FS.Size;
FS.Free;
Doc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // > 0 nghĩa là trường đã được ký
begin
RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
GapStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
TailStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
TailLen := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
Writeln(Names[I], ': signature covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
Các đối số ValueKey ánh xạ sang những mục của dictionary. Khóa 0 trả về khối CMS thô từ /Contents, khóa 2 và 3 trả về tên /Filter và /SubFilter, còn 11 đến 14 trả về bốn con số ByteRange. Giá trị văn bản thì lấy qua GetSignatureTextValueByName: khóa 0 là thời điểm ký được khai báo, còn khóa 5 phân biệt một Sig thông thường với một DocTimeStamp, điều này quan trọng khi một tài liệu mang cả hai
Việc lấy kích thước tệp ở đầu ví dụ đó là chi tiết chịu lực chứ không phải dọn dẹp cho gọn. TPDFlibSignDoc.Open giữ tệp dưới một share lock ngặt nghèo suốt vòng đời của nó, nên bất cứ thao tác nào cần tới byte thô (băm dải đã ký, tính lại digest của CMS) đều phải đọc tệp trước khi gọi Open. Bản demo SigningWorkbench đi kèm thư viện đọc trọn tệp vào bộ nhớ trước cũng chính vì lý do này, và một workbench phớt lờ thứ tự đó sẽ hỏng chập chờn, trên bất kỳ máy nào không may thua cuộc đua
Phép tính ByteRange chứng minh phạm vi bao phủ
Một tệp lành mạnh có một chữ ký duy nhất sẽ mang ByteRange dạng [0 a b c]: phạm vi bao phủ bắt đầu từ offset 0, bỏ qua chỗ giữ /Contents dạng hex nằm giữa a và b, rồi tiếp tục cho tới byte b+c. Khi b+c bằng kích thước tệp, chữ ký bao phủ mọi thứ cho tới cuối tệp, và đó là kết quả bạn muốn. Khi nó hụt đi, tức là ai đó đã nối thêm một incremental update sau khi chữ ký được ghi. Điều đó hoàn toàn hợp lệ theo ISO 32000-1§12.8, vì các lượt điền form về sau, một chữ ký thứ hai, và một DSS dictionary đều đến đúng theo cách này. Đó cũng chính là sự việc mà dấu vết kiểm toán nên ghi lại ngay lúc ký, thay vì dựng lại dưới áp lực khi có tranh chấp
Hãy để mắt tới độ rộng số nguyên khi làm phép tính này. Hàm GetSignProcessByteRange của API phẳng trả về kiểu Integer 32-bit, nhưng giá trị bên dưới là Int64, nên với tệp vượt 2 GB thì bộ truy cập phẳng lặng lẽ cắt cụt. Hãy dùng TPDFlibSigner.GetByteRange ở tầng lớp, vốn trả về Int64, hoặc bóc các giá trị ra từ GetSignatureValueByName theo cách đoạn mã kiểm toán ở trên đã làm
Những phần thư viện để lại cho bạn
Có hai ranh giới nên nắm ngay lúc thiết kế hơn là ở chặng nước rút cuối. API phẳng TPDFlib hoàn toàn không mang lớp bọc nào để xác minh chữ ký. Việc xác minh mật mã nằm ở tầng dưới, trong TPDFlibSignatureVerifier, với hàm VerifySignature trả lời hợp lệ, không hợp lệ, hoặc không xác định. Cũng không có sẵn HTTP client cho các nhà cấp dấu thời gian theo RFC 3161. Thư viện tính hash để gửi đi và nhúng lại khối CMS đã bổ sung khi token quay về, nhưng vòng gọi mạng tới TSA là phần bạn phải tự viết. Cả hai đều dễ bọc lại và thật sự khó chịu khi phát hiện còn thiếu vào tuần trước ngày phát hành, nên hãy thiết kế chúng vào ngay từ bản phác đầu tiên
Có một câu hỏi về tuân thủ đáng được trả lời dứt khoát, vì nó quyết định cửa kiểm tra cuối cùng đặt ở đâu: việc thêm chữ ký có phá vỡ PDF/A không? Tự thân thì không. Chữ ký đến dưới dạng incremental update, và từ ISO 19005-2 trở đi đã cho phép rõ ràng các tài liệu có ký. Điểm mắc nằm ở hình thức hiển thị của chữ ký, vốn chịu cùng luật lệ như mọi nội dung trang khác, gồm cả font nhúng và không dùng màu phụ thuộc thiết bị. Vậy nên cửa cuối cùng trong workbench là thêm một lượt preflight nữa, lần này chạy trên chính tệp đã ký. Hãy coi CheckFileCompliance là bước kiểm tra nhanh ngay trong pipeline và vẫn thẩm định các bản ứng viên phát hành bằng một công cụ độc lập như veraPDF, vì các bộ thẩm định cài đặt những tập luật chồng lấn nhưng không đồng nhất; khi hai bên bất đồng, phần văn bản của phát hiện thường nêu rõ điều khoản cần tra cứu
Một điểm về trình tự rơi ra từ tất cả những điều trên. Ký và đóng dấu thời gian không phải một lượt duy nhất: chữ ký baseline được ghi trước, rồi một tiến trình timestamp riêng bổ sung vào khối CMS bên trong khoảng /Contents đã dành sẵn, và đó chính là lý do dòng reserve-bytes ở trên nặng ký đến vậy. Về các tầng dấu thời gian và thẩm định dài hạn xây trên workbench này, bài hướng dẫn ký và thẩm định PAdES đưa chữ ký từ baseline lên B-LT, còn nửa preflight được đào sâu trong hướng dẫn preflight PDF/A và PDF/UA. Tài liệu API đầy đủ và bản dùng thử nằm trên trang sản phẩm PDF Library for Delphi