Mã hóa một tệp PDF 2 GB nghe giống như một bài toán luồng: mở tệp, đẩy hai gigabyte qua AES-256, ghi kết quả. Mô hình tinh thần đó đã sai theo cách quyết định toàn bộ ngân sách hiệu năng. ISO 32000-1 §7.6 đặt mức độ chi tiết của mã hóa PDF ở từng đối tượng riêng lẻ — mỗi luồng và mỗi chuỗi được mã hóa riêng biệt, mỗi cái có một vector khởi tạo riêng và padding (phần đệm) riêng. Một kho lưu trữ được quét 2 GB với 500.000 đối tượng là 500.000 phép toán CBC nhỏ, không phải một lần quét dài, và ở quy mô đó, chi phí cố định xung quanh mỗi phép toán quan trọng hơn toán học AES bên trong nó
Bài viết này nói về chi phí cố định đó: thời gian đi đâu khi mã Delphi áp dụng AES-256 cho các tài liệu rất lớn, và làm thế nào để lấy lại nó. Về phía thiết lập — mật khẩu, cờ quyền, gọi tương thích phiên bản sửa đổi 5 so với 6 — hãy xem bài viết đồng hành về cấu hình mã hóa AES-256 trong HotPDF; sẽ không có điều nào được lặp lại ở đây
Nửa triệu phép toán CBC, không phải một lần quét
Bộ khung của tệp vẫn ở dạng văn bản thuần túy (plaintext). Bảng tham chiếu chéo, số đối tượng, khóa từ điển, cây trang: không có gì trong số này được mã hóa, đó là cách trình đọc có thể định vị đối tượng trước khi xác thực mật khẩu. Những gì tiêu chuẩn mã hóa là nội dung — dữ liệu luồng như mô tả trang, hình ảnh, phông chữ và tệp đính kèm, cộng với các chuỗi như giá trị siêu dữ liệu và văn bản chú thích. Dưới bộ lọc mã hóa AES-256, mỗi mục được xử lý riêng: một IV 16-byte ngẫu nhiên mới, CBC trên các byte, block padding tới ranh giới 16-byte, và IV được ghi dưới dạng rõ phía trước bản mã (ciphertext)
Kéo theo hai hệ quả. Thứ nhất, bản mã luôn dài hơn bản rõ: IV thêm 16 byte và padding thêm từ 1 đến 16 byte nữa, vì vậy một chuỗi 100 byte chiếm 128 byte trên đĩa và một luồng trống vẫn tạo ra 32 byte. Mã kích thước bộ đệm đầu ra theo độ dài đầu vào, hoặc chỉ ghi lại bao nhiêu byte mà nó đã đọc, sẽ tạo ra các tệp không thể giải mã ở khối cuối cùng của mỗi đối tượng. Thứ hai, chi phí theo dõi số lượng đối tượng, không chỉ số lượng byte. Một kho lưu trữ được quét tập trung byte của nó vào một vài luồng hình ảnh lớn, nhưng lại mang theo hàng trăm nghìn luồng ngắn và chuỗi nhỏ nơi chi phí hoạt động, chứ không phải AES, là hóa đơn
Lòng thương xót duy nhất trong thiết kế AES-256 là xử lý khóa. Các trình xử lý bảo mật cho đến phiên bản sửa đổi 4 đã dẫn xuất một khóa riêng biệt cho mỗi đối tượng bằng cách băm khóa tệp cùng với số đối tượng và số thế hệ, buộc một key schedule (lịch trình khóa) mới mỗi lần. Các lược đồ /V 5 đã loại bỏ dẫn xuất theo từng đối tượng: một khóa tệp ngẫu nhiên 256-bit duy nhất sẽ mã hóa mọi đối tượng trong tài liệu. Sự thật đó cho phép mọi tối ưu hóa dưới đây — trạng thái mật mã đắt đỏ có thể được xây dựng một lần cho mỗi tệp, không phải một lần cho mỗi đối tượng
Từ điển /Encrypt R6: một lần mở chậm, các đối tượng rẻ tiền
Một tài liệu phiên bản sửa đổi 6 khai báo lược đồ của nó trong từ điển /Encrypt của trailer, và các mục quan trọng vừa vặn trong vài dòng:
/Filter /Standard
/V 5 /R 6 /Length 256
/CF << /StdCF << /CFM /AESV3 /Length 32 /AuthEvent /DocOpen >> >>
/StmF /StdCF /StrF /StdCF
/O ...48 bytes... /U ...48 bytes...
/OE ...32 bytes... /UE ...32 bytes...
/Perms ...16 bytes... /P -3904 /EncryptMetadata true
/V 5 chọn kiến trúc khóa 256-bit và /R 6 là bắt tay ISO 32000-2 được củng cố. /CF định nghĩa bộ lọc mã hóa có tên — /AESV3 có nghĩa là AES-256 ở chế độ CBC với IV được nối thêm ở đầu — và /StmF và /StrF gán bộ lọc đó cho các luồng và chuỗi tương ứng. /O, /U, /OE, và /UE chứa dữ liệu xác minh mật khẩu và bao bọc khóa, và /Perms mang một bản sao được mã hóa AES của các bit quyền để một trình soạn thảo thù địch không thể âm thầm lật /P
Cấu trúc chi phí ẩn trong /OE và /UE. Việc mở khóa tệp từ chúng chạy Thuật toán 2.B, một hàm dẫn xuất khóa lặp lại nối tiếp các vòng SHA-256, SHA-384, và SHA-512 — ít nhất 64 vòng, với quy tắc dừng phụ thuộc vào dữ liệu — được xây dựng cố ý chậm để việc đoán mật khẩu vẫn tốn kém. Cái giá đó được trả một lần khi người ghi tạo tệp và một lần khi người đọc mở nó, mỗi lần mất một số mili giây. Trên một tệp có nửa triệu đối tượng, KDF chỉ là nhiễu, và nếu việc lưu bị chậm, Thuật toán 2.B không phải là nghi phạm; mà vòng lặp trên mỗi đối tượng mới là nghi phạm
Tái sử dụng key handle, tái sử dụng bộ đệm trung gian
Cách triển khai ngây thơ là một hàm tiện ích gọn gàng: một trợ thủ EncryptAes256Cbc mở bộ cung cấp Windows CNG, chọn CBC, tạo đối tượng khóa, mã hóa một bộ đệm, và phá bỏ mọi thứ. Chính xác, có thể kiểm thử đơn vị, và thảm họa bên trong một vòng lặp 500.000 lần. Tài liệu của Microsoft đánh dấu BCryptOpenAlgorithmProvider là đắt đỏ và khuyên nên lưu vào bộ đệm (cache) handle, và BCryptGenerateSymmetricKey chạy toàn bộ lịch trình khóa AES và phân bổ trạng thái nhà cung cấp — lãng phí hoàn toàn khi khóa không bao giờ thay đổi trên toàn tài liệu
Delphi RTL không xuất xưởng unit nhập bcrypt nào, vì vậy hãy khai báo trực tiếp các điểm vào (entry points). Lớp dưới đây xây dựng tất cả trạng thái mật mã một lần và sau đó mã hóa bất kỳ số lượng đối tượng nào mà không có sự phân bổ trạng thái ổn định nào:
uses
Winapi.Windows, System.SysUtils, System.Classes;
const
BCRYPT_AES_ALGORITHM = 'AES';
BCRYPT_CHAINING_MODE = 'ChainingMode';
BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
BCRYPT_OBJECT_LENGTH = 'ObjectLength';
BCRYPT_BLOCK_PADDING = $00000001;
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
type
NTSTATUS = Integer;
BCRYPT_HANDLE = Pointer;
function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
if Status <> 0 then
raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
[Api, Cardinal(Status)]);
end;
type
TPdfObjectEncryptor = class
private
FAlg: BCRYPT_HANDLE;
FKey: BCRYPT_HANDLE;
FKeyObject: TBytes; // CNG key-object workspace, allocated once
FScratch: TBytes; // ciphertext scratch, grows and then stays
public
constructor Create(const FileKey: TBytes);
destructor Destroy; override;
procedure EncryptObject(const Plain: TBytes; Dest: TStream);
end;
constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
Mode: string;
ObjLen, Got: ULONG;
begin
inherited Create;
if Length(FileKey) <> 32 then
raise Exception.Create('AES-256 file key must be 32 bytes');
CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
'BCryptOpenAlgorithmProvider');
Mode := BCRYPT_CHAIN_MODE_CBC;
CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
'BCryptSetProperty');
CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
SetLength(FKeyObject, ObjLen);
// The AES key schedule is built once here and reused for every object
CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;
destructor TPdfObjectEncryptor.Destroy;
begin
if FKey <> nil then
BCryptDestroyKey(FKey);
if FAlg <> nil then
BCryptCloseAlgorithmProvider(FAlg, 0);
inherited;
end;
procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
IV, IVWork: array[0..15] of Byte;
Need, Written: ULONG;
Src: PByte;
begin
// Fresh random IV per object; it travels in the clear ahead of the data
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // nil for an empty input is valid: padding-only block
// Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
IVWork := IV; // BCryptEncrypt advances the IV buffer while it chains
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');
if ULONG(Length(FScratch)) < Need then
SetLength(FScratch, Need); // grows a handful of times, then stays put
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// AESV3 layout: the 16-byte IV, then the padded ciphertext
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
Ba chi tiết là trụ cột. Truy vấn kích thước — cuộc gọi BCryptEncrypt đầu tiên, với bộ đệm đầu ra bằng nil — trả về độ dài bản mã đã được đệm, không bao giờ bằng độ dài đầu vào; padding có tính quyết định, vì vậy bạn có thể tự tính toán ((Len div 16) + 1) * 16 và giảm một nửa số cuộc gọi, nhưng truy vấn là hợp đồng đã được ghi tài liệu. Thứ hai, BCryptEncrypt đẩy tới trước bộ đệm IV tại chỗ khi nó tạo chuỗi, vì vậy một bản sao làm việc đi vào mỗi cuộc gọi và IV nguyên sơ rơi vào đầu ra. Thứ ba, FScratch chỉ tăng lên, tới kích thước đối tượng lớn nhất trong tệp, sau đó vòng lặp không phân bổ gì thêm
Tái sử dụng handle đáng giá bao nhiêu, đã được đo lường
Tệp đã buộc bài tập này là một kho lưu trữ khoản vay được quét 1.8 GB: 412.000 đối tượng được mã hóa mang 1.710 MB tải trọng (payload) sau khi cấu trúc bản rõ đã bị trừ đi. Cùng một máy, cùng một tệp, lưu trữ NVMe, một luồng:
- Thiết lập theo từng cuộc gọi (nhà cung cấp được mở và khóa được tạo bên trong trợ thủ): giai đoạn mã hóa 71,3 giây — 1.710 MB ÷ 71,3 giây ≈ 24 MB/giây
- Trạng thái được đưa lên (lớp ở trên): 9,6 giây — 1.710 MB ÷ 9,6 giây ≈ 178 MB/giây
Sự khác biệt là 61,7 giây trên 412.000 cuộc gọi, hay khoảng 150 µs mỗi cuộc gọi dành cho việc mở một nhà cung cấp, thiết lập chế độ tạo chuỗi, và xây dựng lại key schedule cho một khóa không bao giờ thay đổi. Không điều gì trong số đó là mật mã. Với AES-NI, mã hóa CBC các bộ đệm lớn chạy ở mức gần 1,4 GB/giây trên một lõi, vì vậy riêng phần toán học AES chiếm khoảng 1,2 giây trong 9,6; phần lớn phần còn lại là hai lần chuyển BCryptEncrypt theo user-mode cho mỗi đối tượng cộng với việc tạo IV mỗi đối tượng. Phân lô các IV — một cuộc gọi BCryptGenRandom làm đầy 4.096 cái trong số chúng — đã cắt giảm quá trình chạy xuống 8,9 giây. Qua đó bạn đã ở mức sàn mỗi đối tượng của API, và đòn bẩy còn lại là sự song song: các đối tượng /V 5 là độc lập theo khóa tệp chia sẻ, vì vậy bốn luồng công nhân (worker threads) với mỗi luồng một đối tượng khóa đã đưa giai đoạn này xuống còn 3,1 giây trước khi người ghi đầu ra trở thành điểm tuần tự hóa
Viết lại toàn bộ so với lưu gia tăng
Độ chi tiết cũng quyết định một lần lưu tốn bao nhiêu. Việc thêm mã hóa vào tài liệu bản rõ hiện có sẽ định nghĩa lại mỗi đối tượng: mọi luồng và chuỗi thay đổi cả nội dung và độ dài, mọi độ lệch tham chiếu chéo sẽ di chuyển, và không có đường dẫn gia tăng nào tồn tại. Ngân sách cho nó như một quá trình viết lại tuần tự đầy đủ, và ghi vào một tệp tạm thời được đổi tên thành tên đích, vì một sự cố sụp đổ giữa chừng mã hóa nếu không sẽ để lại một tệp nửa bản mã mà không mật khẩu nào có thể mở
Hướng ngược lại thì rẻ hơn. Khi tệp đã được mã hóa, một bản cập nhật gia tăng sẽ nối thêm các đối tượng mới được mã hóa bằng cùng khóa tệp đó và để nguyên mọi byte gốc không bị đụng tới. Đóng dấu một chú thích phê duyệt lên kho lưu trữ đã mã hóa 2 GB tốn vài kilobyte cho bản xuất được nối thêm, không phải là viết lại 2 GB. Hệ quả pipeline là: mã hóa một lần, coi như bước cuối cùng của công việc, và để những lần chạm sau diễn ra trên các lần lưu gia tăng. Một lần thay đổi mật khẩu mà cũng thay đổi khóa tệp lại là một lần viết lại toàn bộ — hãy lên lịch cho nó như vậy
Đo lường thông lượng mà không tự lừa gạt mình
Các tuyên bố về thông lượng mã hóa có xu hướng bị sai ở tử số, mẫu số, hoặc cả hai. Tử số nên là byte payload (tải trọng): tổng độ dài của luồng và chuỗi thực sự đi qua AES, sau khi nén, mà bộ ghi có thể tổng hợp khi nó chạy. Kích thước tệp nói quá lên nó — kho lưu trữ phía trên là 1,8 GB trên đĩa, nhưng chỉ 1.710 MB của nó từng chạm tới cipher (bộ mã hóa). Mẫu số chỉ nên là phần giai đoạn mã hóa, được gói vào bằng TStopwatch từ System.Diagnostics, với quá trình phân tích cú pháp, nén deflate và I/O đĩa nằm ngoài những ngoặc đó. Cuộn những thứ đó vào và đoạn mã mã hóa giống hệt sẽ có đo lường chậm hơn vài lần trên một tệp chỉ nén tệ hơn. Các số liệu phía trên có thể so sánh chính xác bởi vì cả hai phía của phép chia chỉ là mã hóa
Không thứ nào trong số này phải là mã do bạn sở hữu. HotPDF bọc kỹ thuật y hệt phía sau các thuộc tính component — ActivateProtection, CryptKeyLength, UseAES256R6 — ở độ cao phù hợp cho các ứng dụng VCL tương tác, với các cạm bẫy về thứ tự gán được nhắc đến trong bài viết về AES-256 của HotPDF. Đối với các pipeline không cần giám sát, PDFlibPas áp dụng AES-256 phiên bản sửa đổi 6 cho các tệp hiện có trong một cuộc gọi EncryptFile duy nhất ở Strength 4 và xác minh sau đó những gì đã rơi trên đĩa, quy trình làm việc được hướng dẫn trong bài viết về đánh giá mã hóa PDFlibPas
Các đường dẫn mã hóa được mô tả ở đây xuất xưởng trong HotPDF Component cho Delphi và C++Builder và trong thư viện PDFlibPas; cả hai trang sản phẩm đều mang tài liệu tham khảo mã hóa đầy đủ