2 GB'lık bir PDF'yi şifrelemek bir akış (streaming) sorunu gibi gelir: dosyayı açın, iki gigabaytı AES-256 üzerinden geçirin, sonucu yazın. Bu zihinsel model, tüm performans bütçesini belirleyecek bir biçimde yanlıştır. ISO 32000-1 §7.6, PDF şifrelemesinin parçalara ayrılma düzeyini (granularity) bireysel nesneler olarak belirler — her bir akış (stream) ve her bir dize (string) ayrı ayrı, her biri kendi başlatma vektörüne (IV) ve kendi dolgusuna (padding) sahip olacak şekilde şifrelenir. 500.000 nesneye sahip 2 GB'lık taranmış bir arşiv, uzun tek bir geçiş değil, 500.000 küçük CBC işlemidir ve bu ölçekte her bir işlemin etrafındaki sabit maliyet, içindeki AES aritmetiğinden daha fazla önem taşır
Bu makale bu sabit maliyet hakkındadır: Delphi kodu çok büyük belgelere AES-256 uyguladığında zamanın nereye gittiği ve bunun nasıl geri kazanılacağı. Kurulum tarafı için — parolalar, izin bayrakları, revizyon 5'e karşı revizyon 6 uyumluluk çağrısı — HotPDF'te AES-256 şifrelemesini yapılandırma hakkındaki yardımcı makaleye bakın; bunların hiçbiri burada tekrarlanmamaktadır
Yarım milyon CBC işlemi, tek bir geçiş değil
Dosyanın iskeleti düz metin olarak kalır. Çapraz başvuru tabloları, nesne numaraları, sözlük anahtarları, sayfa ağacı: bunların hiçbiri şifrelenmez; bir okuyucu da parolayı doğrulamadan önce nesnelerin yerini bu sayede bulabilir. Standardın şifrelediği şey içeriktir — sayfa açıklamaları, resimler, yazı tipleri ve ekler gibi akış verilerinin (stream data) yanı sıra meta veri değerleri ve ek açıklama metinleri gibi dizeler (strings). AES-256 şifre filtresi altında bunların her biri kendi başına işlenir: yeni ve rastgele 16 baytlık bir IV, baytlar üzerinde CBC, 16 baytlık bir sınıra blok dolgusu (block padding) ve şifreli metnin hemen önüne açık bir şekilde yazılan IV
Bunun iki sonucu vardır. Birincisi, şifreli metin her zaman düz metinden daha uzundur: IV 16 bayt ekler ve dolgu 1 ila 16 bayt daha ekler, bu nedenle 100 baytlık bir dize diskte 128 bayt yer kaplar ve boş bir akış yine de 32 bayt üretir. Çıktı arabelleğini girdi uzunluğuna göre boyutlandıran veya yalnızca okuduğu kadar baytı geri yazan kodlar, her nesnenin son bloğunda şifresi çözülemeyen dosyalar üretir. İkincisi, maliyet yalnızca bayt sayısını değil nesne sayısını da takip eder. Taranmış bir arşiv, baytlarını birkaç büyük resim akışında yoğunlaştırır, ancak faturanın AES değil de işlem başına ek yük (overhead) olduğu yüz binlerce kısa akış ve küçük dize barındırır
AES-256 tasarımındaki tek merhamet anahtar yönetimidir (key handling). Revizyon 4'e kadar olan güvenlik işleyicileri (security handlers), dosya anahtarını nesne ve üretim numaralarıyla birlikte özetleyerek (hashing) her nesne için ayrı bir anahtar türetmiş ve her seferinde yeni bir anahtar zamanlamasını (key schedule) zorunlu kılmıştır. /V 5 şemaları nesne başına türetmeyi bırakmıştır: rastgele bir 256 bitlik dosya anahtarı belgedeki her nesneyi şifreler. Bu gerçek, aşağıdaki her optimizasyona izin verir — pahalı kriptografik durum nesne başına bir kez değil, dosya başına bir kez oluşturulabilir
R6 /Encrypt sözlüğü: bir yavaş açılış, ucuz nesneler
Bir revizyon 6 belgesi, şemasını fragmanın (trailer) /Encrypt sözlüğünde beyan eder ve önemli olan girdiler birkaç satıra sığar:
/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, 256 bitlik anahtar mimarisini ve /R 6 güçlendirilmiş ISO 32000-2 el sıkışmasını (handshake) seçer. /CF adlı şifre filtresini tanımlar — /AESV3, önüne IV eklenmiş CBC modunda AES-256 anlamına gelir — ve /StmF ile /StrF bu filtreyi sırasıyla akışlara (streams) ve dizelere (strings) atar. /O, /U, /OE ve /UE parola doğrulama ve anahtar sarma (key-wrapping) materyallerini tutar ve /Perms izin bitlerinin AES şifreli bir kopyasını taşır, böylece kötü niyetli bir düzenleyici sessizce /P'yi değiştiremez
Maliyet yapısı /OE ve /UE'de saklıdır. Dosya anahtarını bunlardan açmak, SHA-256, SHA-384 ve SHA-512 turlarını zincirleyen tekrarlanan bir anahtar türetme işlevi (KDF) olan Algoritma 2.B'yi çalıştırır — en az 64 tanesi, veriye bağlı bir durdurma kuralı ile — paralo tahmininin pahalı kalması için kasten yavaş oluşturulmuştur. Bu bedel, yazar dosyayı ürettiğinde bir kez ve okuyucu açtığında bir kez olmak üzere her biri tek haneli milisaniyeler civarında ödenir. Yarım milyon nesnelik bir dosyada KDF sadece bir gürültüdür ve eğer kaydetme işlemi yavaşsa, şüpheli Algoritma 2.B değildir; şüpheli olan nesne başına döngüdür
Anahtar tanıtıcısını (key handle) yeniden kullanın, çalışma arabelleğini (scratch buffer) yeniden kullanın
Safça bir uygulama (naive implementation) düzenli bir yardımcı işlevdir: Windows CNG sağlayıcısını açan, CBC'yi seçen, anahtar nesnesini oluşturan, bir arabelleği şifreleyen ve her şeyi kapatan bir EncryptAes256Cbc yardımcısı. Doğru, birim testleri yapılabilir ve 500.000 yinelemeli bir döngü içinde felaket niteliğindedir. Microsoft'un belgeleri BCryptOpenAlgorithmProvider işlevini pahalı olarak işaretler ve tanıtıcının (handle) önbelleğe alınmasını (caching) önerir ve BCryptGenerateSymmetricKey tam AES anahtar zamanlamasını çalıştırır ve sağlayıcı durumunu ayırır — anahtar belge boyunca hiç değişmediğinde bu tam bir israftır
Delphi RTL herhangi bir bcrypt içe aktarma birimiyle (import unit) gelmez, bu yüzden giriş noktalarını (entry points) doğrudan bildirin. Aşağıdaki sınıf, tüm kriptografik durumu bir kez oluşturur ve ardından kararlı durumda (steady-state) hiçbir bellek tahsisi yapmadan istediğiniz sayıda nesneyi şifreler:
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;
Üç ayrıntı yük taşıyıcıdır. Boyut sorgusu — nil bir çıktı arabelleği ile yapılan ilk BCryptEncrypt çağrısı — girdinin uzunluğuna asla eşit olmayan dolgulu şifreli metin uzunluğunu döndürür; dolgu belirleyicidir (deterministic), bu yüzden ((Len div 16) + 1) * 16 hesaplamasını kendiniz yapıp çağrı sayısını yarıya indirebilirsiniz, ancak sorgu belgelenmiş bir sözleşmedir (contract). İkincisi, BCryptEncrypt zincirlendikçe IV arabelleğini yerinde ilerletir, böylece her çağrıya çalışan bir kopya gider ve bozulmamış (pristine) IV çıktıya yansır. Üçüncüsü, FScratch sadece büyür, dosyadaki en büyük nesneye kadar ulaşır ve sonrasında döngü hiçbir tahsis yapmaz
Tanıtıcının yeniden kullanımının ölçülmüş değeri
Bu alıştırmayı zorunlu kılan dosya 1,8 GB'lık taranmış bir kredi arşiviydi: düz metin yapısı çıkarıldıktan sonra 1.710 MB yük taşıyan 412.000 şifrelenmiş nesne. Aynı makine, aynı dosya, NVMe depolama, tek iş parçacığı (thread):
- Çağrı başına kurulum (sağlayıcı açıldı ve anahtar yardımcının içinde oluşturuldu): şifreleme aşaması 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Durum dışarı çekildi (state hoisted) (yukarıdaki sınıf): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
Fark, 412.000 çağrıda 61,7 saniyedir veya çağrı başına kabaca 150 µs; bir sağlayıcıyı açmak, bir zincirleme modu ayarlamak ve hiç değişmeyen bir anahtar için bir anahtar zamanlamasını yeniden oluşturmak için harcanmıştır. Bunların hiçbiri kriptografi değildir. AES-NI ile, büyük arabelleklerin CBC şifrelemesi tek çekirdekte 1,4 GB/s civarında çalışır, bu nedenle AES aritmetiğinin kendisi 9,6 saniyenin yaklaşık 1,2 saniyesini oluşturur; geri kalanın çoğu nesne başına iki kullanıcı modu BCryptEncrypt geçişi artı nesne başına IV üretimidir. IV'lerin toplu işlenmesi (batching) — 4.096 tanesini dolduran tek bir BCryptGenRandom çağrısı — çalışma süresini 8,9 saniyeye düşürdü. Bunu geçtikten sonra API'nin nesne başına taban seviyesindesiniz demektir ve kalan kaldıraç paralelliktir: /V 5 nesneleri paylaşılan dosya anahtarı altında bağımsızdır, bu nedenle her birinin kendi anahtar nesnesi olan dört işçi iş parçacığı (worker thread) çıktı yazarı bir serileştirme noktası (serialization point) haline gelmeden önce aşamayı 3,1 saniyeye indirdi
Tam yeniden yazma ile artımlı (incremental) kaydetme karşılaştırması
Parçalara ayrılma düzeyi (granularity) aynı zamanda bir kaydetme işleminin maliyetini de belirler. Mevcut düz bir metin belgesine şifreleme eklemek, tanımı gereği her nesneyi yeniden yazar: her akış ve dizenin hem içeriği hem de uzunluğu değişir, her çapraz başvuru (cross-reference) ofseti hareket eder ve hiçbir artımlı (incremental) yol yoktur. Bunu tam bir sıralı (sequential) yeniden yazma olarak bütçeleyin ve hedefin üzerine yeniden adlandırılacak geçici bir dosyaya yazın, aksi takdirde şifrelemenin ortasında meydana gelebilecek bir çökme (crash), hiçbir parolanın açamayacağı yarı şifrelenmiş bir dosya bırakır
Ters yön ise ucuz olanıdır. Bir dosya şifrelendikten sonra, artımlı bir güncelleme aynı dosya anahtarıyla şifrelenmiş yeni nesneleri ekler (appends) ve her orijinal baytı dokunulmadan bırakır. 2 GB'lık şifrelenmiş bir arşive onay ek açıklaması basmak, 2 GB'lık bir yeniden yazma işlemine değil, yalnızca birkaç kilobaytlık eklenmiş (appended) çıktıya mal olur. Boru hattı (pipeline) sonucu şudur: işin son adımı olarak bir kez şifreleyin ve sonraki dokunuşların artımlı kayıtlarla devam etmesine izin verin. Dosya anahtarını da değiştiren bir parola değişimi (rotation), yine tam bir yeniden yazma işlemidir — bunu da ona göre planlayın
Kendinizi kandırmadan iş hızını (throughput) ölçmek
Şifreleme iş hızı (throughput) iddiaları payda (numerator), paydada (denominator) veya her ikisinde de yanlış olma eğilimindedir. Pay, yük (payload) baytları olmalıdır: yazarın ilerledikçe toplayabileceği, sıkıştırmadan sonra AES'den fiilen geçirilen akış (stream) ve dize (string) uzunluklarının toplamı. Dosya boyutu bunu abartır — yukarıdaki arşiv diskte 1,8 GB'tır, ancak bunun yalnızca 1.710 MB'ı şifreye dokunur. Payda, ayırma (parsing), indirme (deflate) ve disk G/Ç'si parantez dışında kalacak şekilde, yalnızca System.Diagnostics'teki TStopwatch ile parantez içine alınmış şifreleme aşaması olmalıdır. Bunları da dahil ederseniz, aynı şifreleme kodu, sadece daha kötü sıkıştırılan bir dosyada birkaç kat daha yavaş ölçülecektir. Yukarıdaki rakamların karşılaştırılabilir olmasının nedeni tam olarak bölme işleminin her iki tarafının da sadece şifrelemeye yönelik (encryption-only) olmasıdır
Bunların hiçbirinin kendi yazdığınız bir kod olması gerekmez. HotPDF, etkileşimli VCL uygulamaları için doğru seviyedeki ActivateProtection, CryptKeyLength, UseAES256R6 gibi bileşen özellikleri arkasında aynı mühendisliği sunar; atama sırası tuzakları ise HotPDF AES-256 makalesinde ele alınmıştır. Katılımsız (unattended) boru hatları için PDFlibPas, tek bir EncryptFile çağrısıyla Strength 4'te mevcut dosyalara AES-256 revizyon 6 uygular ve ardından diske neyin indiğini doğrular; bu iş akışı PDFlibPas şifreleme denetimi makalesinde adım adım anlatılmıştır
Burada açıklanan şifreleme yolları, Delphi ve C++Builder için HotPDF Bileşeni'nde ve PDFlibPas kütüphanesi'nde sunulmaktadır; her iki ürün sayfası da eksiksiz şifreleme referansını içerir