기술 문서

PDF 암호화 키가 FPC에서 /dev/urandom을 필요로 하는 이유

Free Pascal에서 런타임 라이브러리 Random 함수로 PDF 암호화 키 재료를 만드는 것은 함정입니다. Randomize를 부르는 코드가 없으면 모든 프로세스가 같은 바이트 시퀀스를 내므로, Linux와 macOS 빌드는 실행할 때마다 동일한 파일 암호화 키, salt, CBC IV, AES-GCM nonce 접두사를 쓰게 됩니다. PDFiumPas는 비 Windows 대상에서 대신 /dev/urandom을 읽고, 그럴 수 없을 때는 예외를 던집니다

함정 자체는 네 줄짜리 루프에 들어갑니다. 더 유용한 교훈은 암호화 테스트 스위트가, AESV3와 AESV4가, PDF MAC을 붙이기도 하고 빼기도 하면서 왜 그 위에서 끝까지 초록을 유지할 수 있었는가입니다. 프로세스마다 상수인 무작위성은 하나의 프로세스 안에서 도는 모든 테스트에 보이지 않고, 암호화 테스트 스위트는 대개 정확히 그렇게 짜여 있습니다

PDFiumPas는 어디서 난수 바이트가 필요할까요?

PDFiumPas 암호화 스택의 모든 난수 바이트는 단 하나의 프로시저, FPdfAes 유닛의 AesGenerateRandomBytes에서 나오므로, 나쁜 소스 하나가 전부를 오염시킵니다. ISO 32000-2 §7.6.4의 표준 보안 핸들러와 ISO/TS 32003의 AESV4 확장은 그 바이트들을 다음 자리에서 소비합니다:

  • DeriveEncryptionKeys가 문서마다 새로 생성하는 32바이트 파일 암호화 키, 이후 비밀번호 유도 키로 감싸 /UE와 /OE에 들어갑니다
  • 16바이트 salt 두 개. 하나는 /U 마지막 16바이트에, 하나는 /O 마지막 16바이트에 저장되며 각각 8바이트 validation salt와 8바이트 key salt로 나뉩니다
  • /Perms 뒤 평문의 12~15바이트. ISO 32000-2가 파일 키로 블록을 암호화하기 전에 난수 데이터로 채웁니다
  • AESV3 문서의 암호화된 문자열과 스트림마다 앞에 붙는 16바이트 CBC IV
  • AESV4 문서의 8바이트 nonce 접두사, 그 뒤를 따르는 0부터 시작하는 객체별 4바이트 카운터
  • EnableIntegrityProtection이 설정됐을 때의 32바이트 /KDFSalt와 MAC 키
PDFiumPas 암호화 스택의 모든 난수 바이트는 FPdfAes의 AesGenerateRandomBytes에서 여섯 소비자로 흐릅니다: /UE와 /OE로 랩핑되는 32바이트 파일 암호화 키, /U와 /O salt, /Perms 패딩 바이트, AESV3 CBC IV, AESV4 GCM nonce 접두사, 그리고 KDF salt와 MAC 키
하나의 공유 생성기는 나쁜 소스 하나가 키 재료를 한꺼번에 전부 오염시킨다는 뜻이고, 그래서 수정이 호출 지점마다가 아니라 단일 프로시저에 들어갔습니다

모든 프로세스가 왜 같은 키를 냈을까요?

RTL 의사난수 생성기는 프로그램이 Randomize를 호출하지 않으면 RandSeed = 0에서 출발하므로, 그것으로 키 버퍼를 채우는 비 Windows 분기는 모든 프로세스에서 같은 시퀀스를 얻습니다. 생성기가 GetTickCount64로 시드된다고 적어 둔 주석은 실제로 그런 코드가 한 줄도 없으면 소용이 없습니다. 주석만이 시드가 존재하는 유일한 장소가 됩니다:

// 함정: 시드되지 않은 RTL 생성기를 키 소스로
// (GetTickCount64 시드를 약속하는 주석은 실제로는 적용하지 않는다)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

시퀀스는 프로세스마다 다시 시작되고 그 안에서만 전진하므로, 어떤 프로세스가 암호화하는 첫 문서는 같은 빌드를 도는 다른 모든 프로세스의 첫 문서와 파일 키를 공유하고, 두 번째는 두 번째끼리 공유합니다. R5, R6, R7의 파일 키는 비밀번호에 전혀 의존하지 않습니다. 비밀번호는 키를 감쌀 뿐이기 때문에, 시퀀스를 재현할 수 있는 사람은 비밀번호를 몰라도 키를 쥡니다. AESV4는 두 번째 실패를 얹습니다. 같은 키에 같은 8바이트 접두사, 0에서 다시 시작하는 카운터면 GCM nonce가 반복되는데, NIST SP 800-38D §8이 이를 명백히 금지합니다. 한 키에서 반복된 GCM nonce는 두 평문의 XOR을 드러내고 인증 서브키를 노출하므로, AESV4-GCM 암호화와 PDF MAC 토큰이 의지하는 태그는 아무 의미도 잃습니다. 기밀성과 무결성이 동시에 날아갑니다

비 Windows FPC 빌드 PDFiumPas의 시드 없는 RTL 생성기: RandSeed 0에서 모든 프로세스가 같은 시퀀스를 내므로 프로세스 A의 첫 문서는 프로세스 B의 첫 문서와 같은 파일 키를 지니고, AESV4는 같은 키가 같은 접두사를 만나 카운터가 0부터 다시 시작하면서 GCM nonce를 반복합니다
파일 키는 비밀번호에 의존하지 않으므로 시퀀스를 재현할 수 있는 사람은 키를 통째로 쥐고, 반복된 GCM nonce는 기밀성과 무결성을 함께 파괴합니다

범위는 앞 문단이 암시하는 것보다 좁습니다. Windows에서는 이 질문이 생기지 않습니다. Windows 분기는 advapi32를 통해 CRYPT_VERIFYCONTEXT로 CryptGenRandom을 호출하고 실패하면 예외를 던지기 때문입니다. 함정은 비 Windows 대상에만 특정되며, 실전에서는 Linux와 macOS의 Lazarus와 Free Pascal 애플리케이션을 뜻하고, PDFium 빌드의 Delphi 대 FPC 함정 목록에 한 줄 더 추가되는 셈입니다

Randomize는 왜 답이 아닐까?

Randomize를 호출하는 것은 근원을 고치지 않은 채 증상만 가립니다. RandSeed는 32비트 값이고 Randomize는 그것을 시계에서 유도하기 때문입니다. 그러면 가능한 키 스트림의 수가 2^32로 갑히고, 파일이 대략 언제 쓰였는지 알면 탐색 공간은 그보다 훨씬 줄어듭니다. 256비트 AES 키 옆에서는 아무것도 아닌 셈입니다. 키 재료는 커널 엔트로피 풀에서 와야 하므로, AesGenerateRandomBytes는 /dev/urandom을 읽고, 짧은 읽기를 루프로 처리하며, 풀이 요청된 바이트를 전부 주지 못하면 예외를 던집니다:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // 실패 또는 예상 밖의 스트림 끝
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

거부하는 것은 의도적이며, CryptGenRandom을 쓸 수 없을 때 Windows 분기가 언제나 해온 것과 같습니다. 실패한 암호화 저장은 당일에 알아차리는 사건이고, 예측 가능한 키로 성공한 저장은 남이 알려 줘야 아는 사건입니다. 실무적 귀결이 둘 따라옵니다. /dev가 채워지지 않은 미니멀 컨테이너나 chroot는 이제 조용히 열화되는 대신 암호화에 실패하니, 마운트하세요. 그리고 예외가 대상 파일을 fmCreate로 연 뒤 TPdf.SaveAsEncrypted 밖으로 전파되므로, 빈 출력 파일이 남고 여러분의 오류 핸들러가 지우게 됩니다

왕복 테스트는 왜 못 잡을까?

왕복 테스트는 상수인 무작위성을 볼 수 없습니다. 복호화는 암호화가 고른 파일 키가 무엇이든 회복해 내기 때문입니다. 테스트는 문서를 암호화하고, 비밀번호로 다시 열고, /UE에서 키를 풀어내고, 모든 객체를 복호화합니다. 예측 가능한 키도 무작위 키만큼이나 잘 풀리고 복호화되며, GCM 태그는 그 같은 키로 계산됐으니 검증을 통과합니다. 두 번 암호화해서 두 출력이 다르다고 단언하는 테스트조차 통과합니다. 같은 프로세스의 두 번째 호출은 시퀀스의 다음 바이트를 뽑을 뿐이기 때문입니다. 중요한 성질, 즉 프로세스마다 다른 키는 출력을 프로세스 간에 비교해야만 관측됩니다. 같은 코드가 어떤 값을 만들고 소비하는 순간, 테스트는 결함 부류 전체에 눈멀어지고 무작위성은 그 가장 순수한 예입니다

키 무작위성을 프로세스 간에 어떻게 테스트할까요?

작은 probe를 대상 플랫폼에서 별개 프로세스로 두 번 돌리고 출력을 비교하세요. 아래 probe는 DeriveEncryptionKeys를 호출하고 /U 항목의 32~47바이트에 저장된 salt를 출력합니다. 그 값은 모든 암호화 파일에 그대로 드러나게 쓰이므로 CI 로그에 찍어도 아무것도 유출되지 않지만, 파일 키와 같은 생성기에서 나옵니다:

PDFiumPas의 프로세스 간 무작위성 테스트: SaltProbe 프로그램이 DeriveEncryptionKeys를 호출해 /U의 32~47바이트를 16진수로 출력하고, job이 그것을 별개 프로세스로 두 번 돌려 두 줄이 같으면 실패하며, 배포된 PDF는 /U 문자열의 마지막 16바이트로 비교합니다
상수인 무작위성은 하나의 프로세스 안에서 보이지 않습니다. 복호화는 암호화가 고른 키가 무엇이든 회복하니, 중요한 성질은 출력을 프로세스 간에 비교해야만 관측됩니다
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = 32바이트 해시 + 16바이트 salt
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // 실행마다 달라야 함
end.

비 Windows 대상마다 probe를 빌드에 연결하세요. 두 번 돌리고 두 줄이 같으면 job을 실패 처리합니다. 같은 비교는 이미 유통 중인 파일에도 통합니다. 같은 애플리케이션의 서로 다른 실행이 쓴 암호화 PDF 두 개를 골라 Encrypt 딕셔너리에서 /U 문자열을 읽고 마지막 16바이트를 비교하세요. 동일한 salt는 키 소스가 예측 가능하다는 뜻이고, 그 문서들은 평문에서 다시 암호화해 각자 새 파일 키를 받아야 합니다. 일반적인 습관은 Windows 실행을 믿기보다 비 Windows 코드 경로를 그 플랫폼 자체에서 돌려 보는 것이고, 비 Windows 빌드용 libcurl 타임스탬프 백엔드가 같은 논리입니다

PDFiumPas는 PDFium 엔진 위에 세워진 Delphi 및 Lazarus PDF 컴포넌트로, AES-256과 AES-GCM, PDF MAC 토큰을 Pascal로 네이티브 구현하고 모든 플랫폼에서 키 재료를 운영체제 생성기에서 가져옵니다. 자세한 내용과 다운로드는 PDFium Delphi 컴포넌트 페이지에 있습니다