기술 문서

PDFlibPas를 사용한 Delphi의 SASLprep AES-256 PDF 비밀번호

비ASCII 비밀번호로 암호화된 AES-256 PDF는 그것을 만든 프로그램에서는 열리지만 다른 어디서도 열리지 않습니다. 원인은 거의 항상 빠진 준비 단계입니다. ISO 32000-2 §7.6.4.3.3은 비밀번호가 UTF-8로 인코딩되고 해시되기 전에 stringprep의 SASLprep 프로필로 처리될 것을 요구합니다. Delphi와 C++Builder용 PDF 라이브러리인 PDFlibPas는 Encrypt, EncryptFile, DecryptFile 내부에서 그 준비 작업을 수행합니다

이는 잘못된 비밀번호 이야기도, 허가 비트 이야기도 아닙니다. 사용자가 여러분이 발급하지 않은 비밀번호를 입력하고 있다면 암호화된 PDF 비밀번호 재시도에 관한 글에서 다루는 재시도 메커니즘이 필요한 것이고, 기존 파일이 실제로 무엇을 강제하는지 알아내려는 것이라면 암호화와 허가 감사가 그 영역을 다룹니다. 이 글은 더 좁고 더 이상합니다. 비밀번호는 맞고, 사용자는 정확히 입력했는데, 파일은 다른 곳에서는 여전히 열리기를 거부합니다

비ASCII 비밀번호는 왜 한 리더에서는 열리고 다른 리더에서는 열리지 않는가

두 프로그램이 같은 키 입력에서 다른 바이트 시퀀스를 해시하기 때문입니다. ISO 32000-2 §7.6.4.3.3의 리비전 6 키 유도는 비밀번호를 UTF-8 바이트로 취해서 127바이트로 잘라내고, 솔트를 붙인 다음, 강화된 해시를 실행합니다. 결과는 암호화 딕셔너리의 /U/O 항목에 대해 검사됩니다. 그 체인의 어떤 것도 모호하지 않습니다. 입력 어디든 한 바이트만 달라도 완전히 다른 다이제스트가 나오고, 검증은 실패하며, 리더가 할 수 있는 말은 딱 하나입니다. 잘못된 비밀번호입니다

바이트가 갈라지는 이유는 유니코드가 겉보기에 같아 보이는 비밀번호를 입력하는 여러 방법을 제공하기 때문입니다. 중국어 비밀번호는 한 입력기에서는 완성형 문자로, 다른 입력기에서는 호환형으로 도착할 수 있습니다. 워드 프로세서에서 복사해온 독일어나 프랑스어 비밀번호는 사용자가 일반 공백이라고 믿는 자리에 줄바꿈 없는 공백(NO-BREAK SPACE, U+00A0)을, 또는 아무것도 아닌 것처럼 렌더링되는 소프트 하이픈(SOFT HYPHEN, U+00AD)을 담고 있을 수 있습니다. SASLprep은 누구든 무언가를 해시하기 전에 이 모든 것을 하나의 정본 형태로 붕괴시키기 위해 존재하며, 그래서 모든 규격 준수 구현이 같은 의도에서 같은 키를 유도하게 됩니다

SASLprep은 비밀번호에 대해 실제로 무엇을 바꾸는가

RFC 4013은 SASLprep을 RFC 3454의 stringprep 프레임워크에 대한 프로필로 정의하며, 이는 하나의 변환이 아니라 네 개의 순서 있는 단계입니다. 매핑이 먼저입니다. RFC 3454 표 C.1.2(비ASCII 공백)는 U+0020으로 매핑되고, 표 B.1(일반적으로 아무것도 아닌 것으로 매핑되는 문자)은 완전히 삭제됩니다. 유니코드 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)은 이를 U+0020으로 매핑하라는 규칙을 가진 C.1.2 범위 U+2000에서 U+200B 안에도 들어가고, 삭제하라는 규칙을 가진 B.1 범위 U+200B에서 U+200D 안에도 들어갑니다. 매핑 단계를 어느 순서로 읽느냐에 따라 같은 비밀번호에서 다른 바이트가 나옵니다. a+U+200B+b는 C.1.2 아래에서는 a b로, B.1 아래에서는 ab로 준비됩니다. 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은 비밀번호를 완전히 거부할 수 있으며, 그럴 때 암호화는 조용히 무언가로 대체하는 대신 크게 실패해야 합니다. EncryptEncryptFileStrength가 3이나 4일 때마다 소유자와 사용자 비밀번호를 둘 다 준비하며, 거부 시 0을 반환하고 LastErrorCode를 604인 PDFLIB_ERROR_PASSWORD_SASLPREP으로 설정합니다. 두 부류의 입력이 이를 촉발합니다. 금지된 출력 표는 제어 문자(C.2.1과 C.2.2), 사적 사용 코드 포인트(C.3), 비문자(C.4), 홀로 있는 서로게이트(C.5), U+FFFD(C.6), 표의문자 서술 문자(C.7), 표시 제어와 태깅 범위(C.8과 C.9)를 거부합니다. 별개로, RFC 3454 6절의 양방향 규칙은 표 D.1의 RandALCat 문자를 포함한 어떤 문자열이든, 그 문자열이 그런 문자로 시작하고 끝나며 왼쪽에서 오른쪽으로 읽는 글자를 전혀 담고 있지 않은 경우가 아닌 한 거부합니다

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 정규화는 Normaliz.dll에서 동적으로 로드되는 Windows NormalizeString API로 수행됩니다. 그 라이브러리를 사용할 수 없을 때는 매핑된 문자열이 정규화되지 않은 채로 사용되며, 이는 매핑과 금지 단계는 여전히 실행되지만 호환성 접기는 일어나지 않는다는 뜻입니다. 실제로 그 DLL은 Vista 이후 모든 Windows 릴리스와 함께 출하되어왔으므로, 저하된 경로는 살아 있는 우려라기보다는 Vista 이전과 비Windows에 관한 문제입니다. 다만 NFKC 접기에 의존하는 비밀번호는 거기서는 다른 바이트를 만들어낼 것이고, 그것은 진짜지만 아주 드문 발산입니다. 양방향 검사는 두 번째 근사치입니다. LCat 문자 감지는 RFC 3454 표 D.2 전체 대신 일반적인 글자 범위를 사용하며, 그 오류의 방향이 이를 받아들일 만하게 만듭니다. 놓친 LCat 문자는 오직 명세라면 거부했을 곳에서 양방향 규칙이 통과하게 만들 뿐, 그 반대는 절대 일어나지 않으며, 매핑이나 정규화 단계는 절대 건드리지 않습니다. 그래서 받아들여진 비밀번호의 준비된 바이트 시퀀스는 변하지 않습니다. 남은 위험은 그러므로 바이트 발산이 아니라 정책 발산입니다. 더 엄격한 구현이라면 아예 받아들이기를 거부했을 이색적인 문자 체계의 비밀번호입니다. 양쪽이 모두 받아들이는 모든 비밀번호는 동일하게 해시되며, 그것이 상호운용성이 실제로 의존하는 속성입니다

마지막으로, 이전에 겪어본 적이 없다면 한 시간을 잡아먹을 Delphi 문법 함정입니다. 함수가 프로시저 타입을 반환할 때, 괄호 없이 대입하는 것은 호출이 아닙니다. 컴파일러는 Proc := GetNormalizeProc;GetNormalizeProc 자체의 주소를 취하는 것으로 읽고, 접근자는 기본 호출 규약을 쓰는데 임포트된 API 타입은 stdcall이라서 호출 규약이 다르다는 도움 안 되는 불평과 함께 E2009를 보고합니다. 빈 괄호는 필수입니다

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 진입점은 Delphi와 C++Builder용 losLab PDF Developer Library Pascal Edition의 일부이며, 제품 페이지에는 전체 암호화 레퍼런스와 완전한 오류 코드 표가 실려 있습니다