PDF, шифрован с AES-256 и парола с не-ASCII символи, се отваря в програмата, която го е записала, и никъде другаде. Причината почти винаги е липсваща стъпка на подготовка: ISO 32000-2 §7.6.4.3.3 изисква паролата да бъде обработена с профила SASLprep на stringprep, преди да бъде кодирана в UTF-8 и хеширана. PDFlibPas, PDF библиотеката за Delphi и C++Builder, извършва тази подготовка вътре в Encrypt, EncryptFile и DecryptFile
Това не е историята за грешна парола и не е историята за битовете за права. Ако потребителите ви въвеждат парола, която никога не сте издали, машинерията за повторен опит в статията за повторни опити с пароли на шифровани PDF файлове е това, което ви трябва, а ако се опитвате да установите какво реално налага съществуващ файл, одитът на шифроване и права покрива тази територия. Тази статия е по-тясна и по-странна: паролата е правилна, потребителят я е въвел правилно, а файлът все пак отказва да се отвори другаде
Защо парола с не-ASCII символи се отваря в един четец, но не в друг?
Защото двете програми хешират различни байтови последователности от едни и същи натискания на клавиши. Извеждането на ключа за ревизия 6 в ISO 32000-2 §7.6.4.3.3 взема паролата като UTF-8 байтове, съкращава до 127 байта, добавя сол и изпълнява подсиления хеш; резултатът се проверява срещу записите /U и /O в речника за шифроване. Нищо в тази верига не е неопределено. Един различаващ се байт навсякъде във входа произвежда напълно различен дайджест, валидацията се проваля и четецът има точно едно нещо, което може да каже: грешна парола
Байтовете се разминават, защото Unicode предлага няколко начина за въвеждане на нещо, което изглежда като една и съща парола. Китайска парола може да пристигне като предварително съставени символи от един метод за въвеждане и като форми за съвместимост от друг. Немска или френска парола, копирана от текстов процесор, може да носи NO-BREAK SPACE (U+00A0) там, където потребителят вярва, че има обикновена интервал, или SOFT HYPHEN (U+00AD), който се рендира като нищо. SASLprep съществува, за да свие всички тях до една канонична форма, преди някой да хешира каквото и да е, така че всяка съвместима имплементация да извежда един и същ ключ от едно и също намерение
Какво всъщност променя SASLprep в паролата?
RFC 4013 дефинира SASLprep като профил на рамката stringprep от RFC 3454, и това са четири подредени стъпки, а не една трансформация. Съпоставянето идва първо: таблица C.1.2 от RFC 3454 (не-ASCII интервали) се съпоставя с U+0020, а таблица B.1 (символи, обичайно съпоставяни с нищо) се изтрива направо. Следва нормализация до Unicode NFKC, стъпката, която сгъва символи за съвместимост и комбиниращи последователности. После проверката за забранен изход отхвърля всичко в таблици C.2.1 до C.9. Накрая двупосочното правило от RFC 3454 раздел 6 се прилага върху нормализирания низ
PDFlibPas имплементира целия профил в модула PDFlibSASLprep, който излага една единствена входна точка. PLSASLprepPassword взема суровата парола, записва подготвената форма във var параметър и връща False, когато паролата трябва да бъде отказана. Функцията е нарочно тотална по щастливия път: парола само с ASCII символи се връща байт идентична, така че нищо в съществуващите разгръщания не се променя
uses
PDFlibSASLprep;
var
Prepared: WideString;
begin
// RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared); // -> 'IX' B.1 deletes SOFT HYPHEN
PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared); // -> 'a b' C.1.2 maps NBSP to U+0020
PLSASLprepPassword(WideString(WideChar($00AA)), Prepared); // -> 'a' NFKC folds ORDINAL INDICATOR
PLSASLprepPassword(WideString(WideChar($2168)), Prepared); // -> 'IX' NFKC folds ROMAN NUMERAL NINE
PLSASLprepPassword('user', Prepared); // -> 'user' ASCII is never touched
end;
Неяснотата с U+200B, която таблиците не разрешават
Една кодова точка попада в две таблици на RFC 3454 едновременно, а двете таблици не са съгласни. ZERO WIDTH SPACE (U+200B) попада в диапазона C.1.2 от U+2000 до U+200B, където правилото казва да се съпостави с U+0020, а също попада и в диапазона B.1 от U+200B до U+200D, където правилото казва да се изтрие. Прочетете стъпката на съпоставяне в който и да е ред и получавате различни байтове от една и съща парола: a+U+200B+b се подготвя до a b според C.1.2 и до ab според B.1. RFC 4013 назовава и двете таблици и не казва коя печели, така че това е истинска неяснота в спецификацията, а не грешка при четене. PDFlibPas проверява принадлежност към C.1.2 първо и следователно съпоставя U+200B с интервал, което е поведението, на което са се спрели други широко разгърнати имплементации на stringprep; съответствието с тях е единственото, което има значение тук, защото целта е байтово съгласие с какъвто и четец да ползва клиентът
Четене на стари файлове: първо подготвена, после сурова
Поправката създава свой собствен проблем със съвместимостта. Всеки AES-256 файл, записан преди промяната, е хеширал суровата UTF-8 парола, така че да направите четеца строго съвместим би заключило клиентите извън собствените им архиви. PDFlibPas решава това от страната на четенето, като пробва два кандидата поред. TPDFDocument.SetPassword изгражда списък от кандидати, който започва с подготвената форма и се връща към суровата форма, и добавя подготвения запис само когато документът е наистина AES-256 и двете форми се различават. За ASCII парола формите са идентични, списъкът съдържа един запис, а цената на целия механизъм е едно единствено сравнение на низове. DecryptFile прави същото по своя пряк път за презапис с AES-256, извиквайки PLDirectDecryptFileAES256 първо с подготвената парола
var
Lib: TPDFlib;
Bytes: AnsiString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.DrawText(100, 100, 'saslprep roundtrip');
// Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
Bytes := Lib.SaveToString;
finally
Lib.Free;
end;
Lib := TPDFlib.Create;
try
// 'pass' is what SASLprep produced and what any conforming reader computes,
// so the plain ASCII form opens a file created with the soft-hyphen form
if Lib.LoadFromString(Bytes, 'pass') = 1 then
Caption := IntToStr(Lib.PageCount);
finally
Lib.Free;
end;
end;
Резервният вариант носи една защита, която си струва да се копира. Вторият опит в DecryptFile се изпълнява само когато подготвената и суровата форма се различават и първият опит е докладвал без твърд код за грешка. Структурен провал означава, че входът е повреден или не е ревизията на шифроването, която сте предположили, а повтарянето на счупен файл с друга парола просто изгаря втори пълен разбор върху враждебен вход; разсъждението зад този рефлекс е изложено в бележката за безопасен разбор на недоверени PDF файлове. Отбележете също, че няма резервен вариант от страната на записа, и тази асиметрия е умишлена. Четенето толерира историята, записването — не: всеки нов AES-256 файл получава съвместимите байтове
Кои пароли биват отхвърлени направо и какво е грешка 604?
SASLprep може да откаже парола изцяло, и когато го направи, шифроването трябва да се провали шумно, а не тихо да замести нещо. Encrypt и EncryptFile подготвят и паролата на собственика, и на потребителя, когато Strength е 3 или 4, връщат 0 при отказ и задават LastErrorCode на PDFLIB_ERROR_PASSWORD_SASLPREP, което е 604. Две семейства входове го задействат. Таблиците за забранен изход отхвърлят контролни символи (C.2.1 и C.2.2), кодови точки за частна употреба (C.3), нехарактери (C.4), самотни surrogates (C.5), U+FFFD (C.6), символи за идеографско описание (C.7) и диапазоните за контрол на показването и таговете (C.8 и C.9). Отделно двупосочното правило от раздел 6 на RFC 3454 отхвърля всеки низ, който съдържа символ RandALCat от таблица D.1, освен ако низът и започва, и завършва с такъв, и не съдържа никакви букви отляво надясно
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
// U+0007 is a C.2.1 control character, so preparation refuses the password
if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
begin
if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then // 604
ShowMessage('The password contains characters that PDF encryption does not permit.');
end;
finally
Lib.Free;
end;
end;
Това двупосочно правило е онова, което ще изненада вашия екип за поддръжка. Арабска или иврит парола, завършваща на западна цифра, или такава с прокрадваща се латинска буква в средата, се отхвърля от спецификацията, макар да изглежда напълно разумна в полето за въвеждане. Показвайте 604 като съобщение за символите в паролата, не като обща грешка при шифроване, иначе някой ще прекара следобед в търсене на бъг във извеждането на ключа ви
Честни граници: NFKC, приблизителен LCat и един капан в Delphi
Две части на имплементацията са приближения, и двете заслужават да бъдат казани ясно, а не заровени. NFKC нормализацията се извършва от Windows API NormalizeString, зареждан динамично от Normaliz.dll. Когато тази библиотека е недостъпна, съпоставеният низ се използва ненормализиран, което означава, че стъпките за съпоставяне и забрана все още се изпълняват, но сгъването за съвместимост не. На практика тази DLL се доставя с всяко издание на Windows от Vista насам, така че деградиралият път е грижа за пре-Vista и не-Windows системи, а не жива грижа, но парола, която разчита на сгъването на NFKC, би произвела различни байтове там, и това е реално, макар и отдалечено, разминаване. Проверката за двупосочност е второто приближение: откриването на символи LCat използва обичайните диапазони от букви вместо пълната таблица D.2 на RFC 3454, а посоката на тази грешка е това, което я прави приемлива. Пропуснат символ LCat може само да накара двупосочното правило да пропусне там, където спецификацията би отхвърлила, никога обратното, и никога не докосва стъпките на съпоставяне или нормализация, така че подготвената байтова последователност на приета парола остава непроменена. Остатъчният риск следователно е разминаване в политиката, не в байтовете: парола с екзотична писменост, която по-строга имплементация би отказала изцяло да приеме. Всяка парола, приета от двете страни, хешира идентично, а това е свойството, от което взаимната съвместимост наистина зависи
Накрая, капан в синтаксиса на Delphi, който струва час, ако не сте го срещали преди. Когато функция връща процедурен тип, присвояването ѝ без скоби не я извиква. Компилаторът чете Proc := GetNormalizeProc; като вземане на адреса на самата GetNormalizeProc, после докладва E2009 с неполезното оплакване, че конвенциите за извикване се различават, защото акцесорът използва конвенцията по подразбиране, докато импортираният API тип е stdcall. Празните скоби са задължителни
type
TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
DstString: PWideChar; DstLength: Integer): Integer; stdcall;
function GetNormalizeProc: TNormalizeString; // loads Normaliz.dll on first use
...
var
Proc: TNormalizeString;
begin
// Proc := GetNormalizeProc; // E2009: reads as @GetNormalizeProc, conventions differ
Proc := GetNormalizeProc(); // correct: calls the accessor and assigns its result
if not Assigned(Proc) then
Exit; // no NFKC available, mapped string is used as-is
end;
Подготовката на пароли е една от онези подробности, които никога не се появяват в списък с функции, но решават дали шифрован документ преживява контакт с клиент от друг локал. Входните точки Encrypt, EncryptFile, DecryptFile и SetPassword, описани тук, са част от losLab PDF Developer Library Pascal Edition за Delphi и C++Builder, чиято страница на продукта носи пълната справка за шифроване и пълната таблица с кодове за грешки