Teknisk artikkel

Høyhastighets AES-256 PDF-kryptering for massive dokumenter

Kryptering av en 2 GB PDF høres ut som et strømme-problem: åpne filen, skyv to gigabyte gjennom AES-256, skriv resultatet. Den mentale modellen er feil på en måte som bestemmer hele ytelsesbudsjettet. ISO 32000-1 §7.6 setter granulariteten for PDF-kryptering til det enkelte objekt — hver strøm og hver streng krypteres separat, hver med sin egen initialiseringsvektor og sin egen fylling (padding). Et 2 GB skannet arkiv med 500 000 objekter er 500 000 små CBC-operasjoner, ikke én lang gjennomkjøring, og i den skalaen betyr den faste kostnaden rundt hver operasjon mer enn AES-aritmetikken inni den

Denne artikkelen handler om den faste kostnaden: hvor tiden forsvinner når Delphi-kode bruker AES-256 på svært store dokumenter, og hvordan du får den tilbake. For oppsettssiden — passord, tillatelsesflagg, kallet for kompatibilitet for revisjon 5 kontra 6 — se følgesvennartikkelen om konfigurering av AES-256 kryptering i HotPDF; ingenting av dette gjentas her

En halv million CBC-operasjoner, ikke én gjennomkjøring

Filens skjelett forblir i klartekst. Kryssreferansetabeller, objektnumre, ordboknøkler, sidetreet: ingenting av det er kryptert, noe som er grunnen til at en leser kan lokalisere objekter før den har validert et passord. Det standarden krypterer er innhold — strømdata som sidebeskrivelser, bilder, skrifttyper og vedlegg, pluss strenger som metadataverdier og annoteringstekst. Under AES-256 crypt-filteret behandles hver for seg: en ny tilfeldig 16-byte IV, CBC over bytene, blokkfylling (padding) til en 16-byte grense, og IV-en skrevet i klartekst foran chifferteksten

To konsekvenser følger. For det første, chiffertekst er alltid lengre enn klartekst: IV-en legger til 16 byte og fyllingen legger til 1 til 16 mer, så en streng på 100 byte opptar 128 byte på disken, og en tom strøm produserer fremdeles 32. Kode som dimensjonerer utdatabufferen til inndatalengden, eller som bare skriver tilbake like mange byte som den leste, produserer filer som feiler i dekrypteringen ved siste blokk for hvert objekt. For det andre, kostnad følger objektantallet, ikke bare byteantallet. Et skannet arkiv konsentrerer sine byte i noen få store bildestrømmer, men bærer hundretusener av korte strømmer og små strenger der overhodet (overhead) per operasjon, ikke AES, er regningen

Den ene barmhjertigheten i AES-256-designet er nøkkelhåndtering. Sikkerhetshåndterere (security handlers) opp til revisjon 4 avledet en distinkt nøkkel for hvert objekt ved å hashe filnøkkelen sammen med objekt- og generasjonsnumrene, noe som tvang frem en ny nøkkelplan hver gang. /V 5-ordningene droppet avledning per objekt: en tilfeldig 256-bit filnøkkel krypterer hvert objekt i dokumentet. Det faktum tillater hver optimalisering nedenfor — den kostbare kryptografiske tilstanden kan bygges én gang per fil, ikke én gang per objekt

R6 /Encrypt-ordboken: en langsom åpning, billige objekter

Et revisjon 6-dokument erklærer sin ordning i trailerens /Encrypt-ordbok, og postene som betyr noe passer på noen få linjer:

/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 velger den 256-bits nøkkelarkitekturen og /R 6 det forsterkede ISO 32000-2 håndtrykket. /CF definerer det navngitte crypt-filteret — /AESV3 betyr AES-256 i CBC-modus med foranstilt IV — og /StmF og /StrF tildeler det filteret til henholdsvis strømmer og strenger. /O, /U, /OE og /UE holder passordverifiseringen og nøkkelinnpakningsmaterialet, og /Perms bærer en AES-kryptert kopi av tillatelsesbittene, slik at en fiendtlig redaktør ikke i stillhet kan snu /P

Kostnadsstrukturen gjemmer seg i /OE og /UE. Å pakke ut filnøkkelen fra dem kjører Algoritme 2.B, en iterert nøkkel-avledningsfunksjon (KDF) som kjeder sammen SHA-256, SHA-384 og SHA-512-runder — minst 64 av dem, med en dataavhengig stoppregel — bevisst bygget tregt, slik at passordgjetting forblir dyrt. Den prisen betales én gang når skriveren produserer filen og én gang når en leser åpner den, ensifrede millisekunder hver. På en fil med en halv million objekter er KDF-en bare støy, og hvis en lagring er treg, er det ikke Algoritme 2.B som er den mistenkte; løkken per objekt er

Gjenbruk nøkkelhåndtaket, gjenbruk arbeidsbufferen

Den naive implementeringen er en ryddig verktøyfunksjon: en EncryptAes256Cbc-hjelper som åpner Windows CNG-tilbyderen, velger CBC, genererer nøkkelobjektet, krypterer én buffer, og river alt ned. Riktig, enhets-testbar og katastrofal inne i en løkke med 500 000 iterasjoner. Microsofts dokumentasjon markerer BCryptOpenAlgorithmProvider som dyr, og anbefaler caching av håndtaket, og BCryptGenerateSymmetricKey kjører den fulle AES-nøkkelplanen og allokerer tilbydertilstand — rent sløseri når nøkkelen aldri endres over dokumentet

Delphi RTL sender ingen bcrypt-importenhet, så erklær inngangspunktene direkte. Klassen nedenfor bygger all kryptografisk tilstand én gang, og krypterer deretter ethvert antall objekter uten jevn allokering:

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;

Tre detaljer bærer lasten. Størrelsesforespørselen — det første BCryptEncrypt-kallet, med en nil utdatabuffer — returnerer den fylte (padded) chiffertekstlengden, aldri lik inndatalengden; fylling er deterministisk, så du kan beregne ((Len div 16) + 1) * 16 selv og halvere kalletellinger, men forespørselen er den dokumenterte kontrakten. For det andre, BCryptEncrypt rykker IV-bufferen fremover (advances) på plass mens den kjeder (chains), så en arbeidskopi går inn i hvert kall, og den urørte IV-en lander i utdataen. For det tredje, FScratch bare vokser, opp til det største objektet i filen, og deretter allokerer ikke løkken noe

Hva gjenbruk av håndtak er verdt, målt

Filen som tvang frem denne øvelsen, var et 1,8 GB skannet lånearkiv: 412 000 krypterte objekter som bærer 1710 MB nyttelast når klartekststrukturen er trukket fra. Samme maskin, samme fil, NVMe lagring, én tråd:

  • Oppsett per kall (tilbyder åpnet og nøkkel generert inne i hjelperen): krypteringsfase 71,3 s — 1710 MB ÷ 71,3 s ≈ 24 MB/s
  • Tilstand løftet ut (klassen over): 9,6 s — 1710 MB ÷ 9,6 s ≈ 178 MB/s

Forskjellen er 61,7 s fordelt på 412 000 kall, eller omtrent 150 µs per kall, brukt på å åpne en tilbyder, angi en kjedemodus (chaining mode) og gjenoppbygge en nøkkelplan for en nøkkel som aldri endret seg. Ingenting av det var kryptografi. Med AES-NI kjører CBC-kryptering av store buffere nær 1,4 GB/s på én kjerne, så selve AES-aritmetikken utgjør omtrent 1,2 s av de 9,6; mesteparten av resten er de to brukermodus- BCryptEncrypt-overgangene per objekt, pluss IV-generering per objekt. Batching av IV-er — ett BCryptGenRandom-kall som fyller 4 096 av dem — trimmet kjøringen ned til 8,9 s. Forbi det er du på API-ets bunnivå per objekt, og den gjenværende spaken er parallellisme: /V 5-objekter er uavhengige under den delte filnøkkelen, så fire arbeidertråder med ett nøkkelobjekt hver tok fasen ned til 3,1 s, før utdataskriveren ble serialiseringspunktet

Full omskriving versus inkrementell lagring

Granularitet bestemmer også hva en lagring koster. Å legge til kryptering i et eksisterende klartekstdokument skriver om hvert objekt per definisjon: hver strøm og streng endrer både innhold og lengde, hver kryssreferanseforskyvning flyttes, og det finnes ingen inkrementell vei. Budsjett for det som en full, sekvensiell omskriving, og skriv til en midlertidig fil som døpes om over målet, fordi et krasj midt i krypteringen ellers etterlater en halvkryptert fil som intet passord vil åpne

Den omvendte retningen er den billige. Når en fil er kryptert, legger en inkrementell oppdatering til nye objekter kryptert med samme filnøkkel, og etterlater hver opprinnelige byte urørt. Å stemple en godkjenningsannotering på et 2 GB kryptert arkiv koster kilobyte med lagt til utdata, ikke en 2 GB omskriving. Pipeline-følgen: krypter én gang, som jobbens siste trinn, og la etterfølgende berøringer ri på inkrementelle lagringer. En passordrotasjon som også roterer filnøkkelen, er en full omskriving igjen — planlegg det som en

Måle gjennomstrømning uten å lure deg selv

Påstander om krypteringsgjennomstrømning pleier å være feil i telleren, nevneren, eller begge deler. Telleren bør være nyttelastbyte: summen av strøm- og strenglengder som faktisk dyttes gjennom AES, etter komprimering, som skriveren kan summere opp underveis. Filstørrelse overvurderer det — arkivet ovenfor er 1,8 GB på disk, men bare 1710 MB av det rører noen gang chifferet. Nevneren bør være selve krypteringsfasen, innesperret med TStopwatch fra System.Diagnostics, med parsing, dekomprimering (deflate) og disk-I/O utenfor klammene. Folder du disse inn, vil den identiske krypteringskoden måle flere ganger saktere på en fil som ganske enkelt komprimerer dårligere. Tallene ovenfor er sammenlignbare nettopp fordi begge sider av divisjonen kun omfatter kryptering

Ingenting av dette må være kode du eier selv. HotPDF pakker inn den samme ingeniørkunsten bak komponentegenskaper — ActivateProtection, CryptKeyLength, UseAES256R6 — på riktig nivå for interaktive VCL-applikasjoner, med tildelingsrekkefølge-fellene dekket i HotPDF AES-256 artikkelen. For ubetjente pipelines (unattended pipelines) bruker PDFlibPas AES-256 revisjon 6 på eksisterende filer i ett enkelt EncryptFile-kall ved styrke 4 (Strength 4), og verifiserer etterpå hva som landet på disken, en arbeidsflyt som gjennomgås i PDFlibPas krypteringsrevisjonsartikkelen

Krypteringsveiene beskrevet her leveres med HotPDF Component for Delphi og C++Builder, og i PDFlibPas biblioteket; begge produktsidene bærer den fullstendige krypteringsreferansen