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 키
모든 프로세스가 왜 같은 키를 냈을까요?
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에서는 이 질문이 생기지 않습니다. 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 로그에 찍어도 아무것도 유출되지 않지만, 파일 키와 같은 생성기에서 나옵니다:
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 컴포넌트 페이지에 있습니다