PDFlibPas는 단일 Keccak-f[1600] permutation 위에서 SHA-3과 SHAKE256을 Object Pascal로 구현합니다. 두 패밀리는 정확히 두 곳에서 갈립니다. FIPS 202 §6.2와 §6.3에 따라 SHA-3은 $06, SHAKE는 $1F인 pad10*1 도메인 바이트, 그리고 rate 규칙입니다. SHAKE256은 출력 길이와 무관하게 rate를 136바이트로 고정합니다
두 번째 차이는 사람들이 틀리는 부분인데, 전혀 차이처럼 보이지 않기 때문입니다. SHA3-256도 rate 136으로 돌아가므로, SHA-3 코드 경로를 복사해서 만든 SHAKE256은 던지는 모든 32바이트 테스트를 통과하다가 누군가 64바이트를 처음 요구하는 순간 쓰레기를 만듭니다. 아래에서는 두 패밀리가 PDFlibDigest에서 하나의 context record를 공유하게 된 과정, 원샷 엔트리 포인트가 멈추는 지점, 그리고 Ed448이나 ML-DSA가 가능해지기 전에 스트리밍 XOF가 왜 존재해야 했는지를 다룹니다
SHA-3과 SHAKE256은 왜 Keccak context를 공유할까요?
패밀리별 상태가 단일 바이트뿐이고 PDFlibPas가 그것을 TSHA3Context의 Suffix로 저장하기 때문입니다. 도메인 구분자가 finalization 루틴에 하드코딩되는 대신 context에 살면, SHA3AbsorbBlock, SHA3Update, SHA3Final은 두 패밀리를 수정 없이 서빙하며 라이브러리에는 24라운드 permutation이 정확히 한 벌만 있습니다. 두 경로가 갈리는 곳은 이니셜라이저이며, 완전히 갈립니다
Type
TSHA3Context= Record
State: Array [0.. 24] Of UInt64;
Buffer: Array [0.. 135] Of Byte;
BufferLength: Integer;
Rate: Integer;
OutputLength: Integer;
// 도메인 구분자: SHA3은 $06, SHAKE는 $1F (FIPS 202 §6.2/6.3)
Suffix: Byte;
End;
Procedure SHA3Init(Var Context: TSHA3Context; OutputBits: Integer);
Begin
FillChar(Context, SizeOf(Context), 0);
Context.OutputLength:= OutputBits div 8;
Context.Rate:= 200- 2* Context.OutputLength; // rate는 다이제스트 크기를 따라감
Context.Suffix:= $06;
End;
// SHAKE256: 호출자가 무엇을 요구하든 rate는 136바이트로 고정,
// SHAKE는 extendable-output 함수이기 때문
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
FillChar(Context, SizeOf(Context), 0);
Context.OutputLength:= OutputBytes;
Context.Rate:= 136;
Context.Suffix:= $1F;
End;
SHA3Init의 공식은 SHA3-256, SHA3-384, SHA3-512에 대해 각각 136, 104, 72바이트의 rate를 주므로, 고정 길이 패밀리에서는 rate와 보안 레벨이 함께 용접되어 있습니다. SHAKE256Init은 그 연결을 일부러 끊습니다. OutputLength는 보안 강도의 선언이 아니라 바이트 요청이 되고, 출력을 얼마나 뽑든 sponge 용량은 512비트로 유지됩니다. 이 디커플링이 곧 extendable-output 함수의 정의이며, 하나의 context 위에 두 이니셜라이저로 써 두면 그 구분이 주석 속에 묻히지 않고 소스에서 보입니다
원샷 엔트리 포인트의 136바이트 상한
PDFlibPas의 편의 함수들은 1..136바이트 밖을 전부 거부하며, 그 한계는 임의적이지 않고 정직합니다. SHAKE256StreamRange와 SHAKE256ByteArray는 둘 다 SHA3Final을 거칩니다. 마지막 블록을 패딩하고 한 번 permute한 뒤 state에서 바로 출력을 읽습니다. 그 state의 처음 Rate바이트만이 합법적인 출력이라, 한 번의 squeeze는 최대 136바이트를 줍니다. 그 이상은 블록마다 새 permutation이 필요합니다. 실제 소비자 둘 다 어차피 이 창 안에 들어갑니다. PDF 서명 프로필은 32바이트를 다이제스트하고, Ed448은 RFC 8032 §5.2에 따라 114바이트가 필요합니다. SHAKE256StreamRange는 SHA256StreamRange와 SHA3_256StreamRange와 같은 FirstLength, SecondStart, SecondLength 트리플을 받으므로, 검증 코드는 모든 알고리즘에 대해 같은 방식으로 서명 /ByteRange를 훑고 XOF를 특수 취급하지 않습니다
위치가 rate-1에 닿을 때 pad10*1에서 무슨 문제가 생길까요?
두 패드 바이트를 할당이 아니라 state에 XOR하면 아무 문제가 없습니다. FIPS 202 §B.2는 도메인 suffix를 현재 absorb 위치에, $80을 블록의 마지막 바이트에 둡니다. 메시지가 우연히 위치를 Rate- 1에 남기면, 두 쓰기가 같은 바이트를 겨냥해 $9F로 결합되어야 합니다. XOR 대신 저장하면 하나만 이기고, 패딩은 136분의 1 정확히 하나의 메시지 길이에서 조용히 틀리며, 소유한 회귀 벡터는 전부 그 자리에 안 걸리므로 다 통과합니다. PDFlibPas의 PLShakeXOFFinalize는 둘 다 XOR해서 겹침 케이스가 분기 없이 일반 케이스에서 자연히 나옵니다
// 마지막 부분 블록을 패딩하고 squeeze 단계에 들어감
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
If Not Ctx.Absorbing Then
Exit;
Ctx.Absorbing:= False;
// suffix는 SqueezePos에, $80은 Rate-1에; SqueezePos = Rate-1이면 둘은
// 같은 바이트에 XOR되어 올바르게 $9F로 결합
Ctx.State[Ctx.SqueezePos div 8]:= Ctx.State[Ctx.SqueezePos div 8]xor
(UInt64(Ctx.Suffix)shl (8* (Ctx.SqueezePos and 7)));
Ctx.State[(Ctx.Rate- 1)div 8]:= Ctx.State[(Ctx.Rate- 1)div 8]xor
(UInt64($80)shl (8* ((Ctx.Rate- 1)and 7)));
SHA3Permute(Ctx.State);
Ctx.SqueezePos:= 0;
End;
TPLShakeXOF: sponge에 직접 absorb하기
PDFlibPas는 별도의 record인 TPLShakeXOF로 무제한 출력을 노출합니다. 25개 UInt64 워드의 State, Rate, Suffix, Absorbing 플래그, SqueezePos만 담습니다. 메시지 버퍼는 없습니다. PLShakeXOFAbsorb는 들어오는 바이트를 state에 직접 XOR하고 블록이 찰 때마다 permute하며, SqueezePos가 두 단계에서 오프셋 역할을 합니다. PLShakeXOFInit은 불리언 하나로 변형을 고릅니다. True는 rate 168의 SHAKE128, False는 rate 136의 SHAKE256이며, PLShakeXOFSqueeze는 호출자가 요청한 바이트 수를 얻을 때까지 계속 permute합니다. 블록을 가로지르는 출력은 두 변형 모두에서 첫 블록 경계를 넘어서는 200바이트에서 검증됐습니다
Var
Ctx: TPLShakeXOF;
Digest: Array [0.. 113] Of Byte; // Ed448은 114바이트가 필요, RFC 8032 §5.2
Begin
PLShakeXOFInit(Ctx, False); // False = SHAKE256, rate 136
PLShakeXOFAbsorb(Ctx, @Dom4[1], Length(Dom4));
PLShakeXOFAbsorb(Ctx, @Prefix[1], Length(Prefix));
PLShakeXOFAbsorb(Ctx, @Msg[1], Length(Msg));
PLShakeXOFFinalize(Ctx);
PLShakeXOFSqueeze(Ctx, @Digest[0], SizeOf(Digest));
End;
// 또는 버퍼 하나 넣고 하나 받으면 충분할 때:
// PLShakeXOF(False, Input, Output, 114)
여러 호출로 absorb하는 것이 보기보다 중요합니다. Ed448은 해시 전에 메시지에 dom4 문자열과 57바이트 값을 접두사로 붙이고, rejection sampler는 문자열을 먼저 연결하지 않고 시드와 인덱스 바이트를 넣고 싶어 합니다. 어느 소비자보다 먼저 스트리밍 record를 만드는 것이 핵심이었습니다. 순수 Pascal Ed448과 Brainpool 곡선 작업은 한 번에 114바이트가 필요하고, FIPS 204 ML-DSA 포트는 두 변형에서 무한정 squeeze합니다. 둘 다 원샷 API로는 작성할 수 없었습니다
PDFlibPas는 왜 SHAKE256을 다이제스트 선택자로 노출하지 않을까요?
합법적인 선택 방법이 없기 때문입니다. PDFlibDigitalSign의 TPDFlibDigestAlgorithm은 daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512까지 가고 멈춥니다. daSHAKE256은 없으며, 그 생략은 실수가 아니라 결정입니다. RFC 8702는 CMS에 SHAKE를 등록하지만, PDF 서명 안에서 ISO/TS 32002가 SHAKE256을 두는 유일한 자리는 Ed448의 내부 다이제스트입니다. 서명 알고리즘이 그것을 고르지 호출자가 고르지 않습니다. 자유롭게 쓰는 DigestAlgorithm 값으로 공개하면 누구나 SHAKE256을 RSA나 ECDSA와 짝지어 어떤 검증기도 받아들이지 않을 서명 딕셔너리를 만들 수 있습니다. 기능은 출하되고 선택자는 출하되지 않으며, 검증 쪽 코드는 /ByteRange 레이아웃 위에서 SHAKE256StreamRange를 직접 호출합니다. 상대방이 오늘 검증해야 할 서명의 다이제스트를 고른다면, 타임스탬프와 장기 검증 데이터를 갖춘 PAdES B-B에서 B-LTA 워크플로 안의 daSHA256부터 daSHA3_512가 여전히 답입니다. SHA-3이 PDF에서 일상이 되는 시점은 ISO/TS 32001이 규율합니다
XOF 테스트: 통과하는 것처럼 보이는 벡터
전체 출력 길이를 비교하고, 벡터를 하나 이상 쓰세요. 빈 문자열 SHAKE256 결과는 모두가 외우는 것이며, 너무 널리 인용되는 탓에 정확히 그 때문에 약한 테스트입니다. abc의 32바이트 SHAKE256 출력은 더 나쁩니다. 처음 16바이트인 483366601360a8771c6863080cc4114d가 주변 여러 벡터와 일치해서, 잘린 비교는 성공을 보고하지만 판별력 있는 절반인 8db44530f8f1e1ee4f94ea37e78b5739는 검사되지 않습니다. PDFlibPas 회귀 테스트는 두 벡터를 전체 길이로 비교합니다. 크로스 블록 케이스도 추가하세요. rate 경계를 한 번도 넘지 않는 squeeze 루프는 전혀 테스트되지 않은 것입니다
SHAKE를 기존 SHA-3 구현에 접는 데서 얻는 더 큰 교훈은, 공유 코드는 결코 위험이 아니었다는 것입니다. Keccak-f[1600]은 두 패밀리에서 같은 permutation이며 벡터와 맞거나 안 맞거나 둘 중 하나입니다. 위험은 달라지는 두 바이트와 정수 하나, 그리고 호출자가 발견하게 두지 말고 정직하게 명시해야 하는 상한에 있습니다. 네이티브 Object Pascal 코드베이스에서 다이제스트 레이어가 서명, 암호화, PDF/A 툴링과 어떻게 어울리는지 보려면, PDFlibPas Delphi PDF 라이브러리 제품 페이지에서 전체 컴포넌트 구성과 지원 컴파일러 매트릭스를 확인하세요