PDFiumPas는 Windows, Linux와 macOS에서 PKCS#11 token으로 PAdES document에 서명하며 이 binding이 작동하는지는 두 platform fact가 결정합니다. CK_ULONG은 C의 unsigned long이므로 Windows에서는 4 byte이고 Linux와 macOS에서는 8 byte이며 PKCS#11 header는 Windows에서만 #pragma pack(1)을 적용해 function table의 모든 pointer를 이동시킵니다. 둘 중 하나라도 틀리면 module은 여전히 load되고 call도 여전히 return하지만 돌아오는 숫자는 garbage입니다. 이것이 예상해야 하는 bug의 모양입니다. 아무것도 link되지 않으므로 linker error가 나오지 않습니다. module은 runtime에 path로 여는 .so, .dylib 또는 .dll이고 전체 surface는 cast해 call하는 function pointer의 struct입니다. compiler는 반대편의 C header가 어떻게 생겼는지 알 수 없습니다. mismatch는 crash가 될 때까지 조용합니다
PKCS#11 binding이 clean error 대신 random CKR code로 실패하는 이유
ABI mismatch는 error condition을 만들지 않고 wrong address 또는 wrong offset을 만들며 token은 그 결과가 된 질문에 성실하게 답하기 때문입니다. record declaration과 module 사이에는 불일치를 알아챌 layer가 없습니다. 두 가지 별도 failure mode가 나옵니다. packing이 틀리면 C_GetSlotList로 읽은 slot에는 pointer 하나의 6 byte와 다음 pointer의 2 byte가 들어가며 call은 unmapped memory 또는 더 나쁘게 다른 function의 중간으로 jump할 수 있습니다. 이것이 access violation입니다. CK_ULONG의 width가 틀리면 address는 괜찮지만 data가 틀립니다. LP64 module이 8 byte를 써 주는데 var Count: CK_ULONG out-parameter를 4 byte로 선언하면 stack frame의 다음 4 byte를 조용히 덮어쓰고 CK_ATTRIBUTE template에서 ValueLen이 잘못된 offset에 놓이면 module이 Value pointer에서 length field를 읽습니다. 그러면 token은 실제로 묻지 않은 질문에 대해 완전히 합법적인 CKR_BUFFER_TOO_SMALL 또는 CKR_ATTRIBUTE_VALUE_INVALID를 반환합니다. 이 code는 사람을 token configuration으로 몇 시간 동안 쫓아내지만 bug는 type declaration 네 줄 위에 있습니다
CK_ULONG은 fixed-width type이 아니라 C unsigned long
CK_ULONG은 PKCS#11 header에서 C unsigned long으로 정의되므로 width가 specification이 아니라 platform data model을 따릅니다. Windows는 LLP64라서 64-bit process에서도 unsigned long이 32-bit로 남습니다. Linux와 macOS는 LP64이므로 pointer를 따라 64-bit가 됩니다. 이것은 전체 unit에서 가장 영향이 큰 한 줄입니다. PKCS#11에서는 거의 모든 scalar가 CK_ULONG이기 때문입니다. slot ID, session handle, object handle, object class, key type, attribute type, mechanism type, buffer length와 CK_RV return value 자체가 모두 해당합니다
type
{$IFDEF MSWINDOWS}
// Windows는 LLP64이므로 C unsigned long이 그곳에서는 32-bit입니다
CK_ULONG = LongWord;
{$ELSE}
// Linux와 macOS는 LP64이므로 unsigned long이 pointer width를 따릅니다
CK_ULONG = PtrUInt;
{$ENDIF}
CK_RV = CK_ULONG;
CK_FLAGS = CK_ULONG;
CK_SLOT_ID = CK_ULONG;
CK_SESSION_HANDLE = CK_ULONG;
CK_OBJECT_HANDLE = CK_ULONG;
CK_OBJECT_CLASS = CK_ULONG;
CK_ATTRIBUTE_TYPE = CK_ULONG;
CK_MECHANISM_TYPE = CK_ULONG;
PCK_ULONG = ^CK_ULONG;
모든 것을 LongWord나 UInt64로 직접 alias하지 않고 CK_ULONG에 alias하는 것이 이 작업의 핵심입니다. conditional이 정확히 한 곳에 나타난다는 뜻입니다. 어느 하나라도 concrete type으로 적으면 future port가 밟을 landmine을 만들며 그 landmine은 잊은 바로 그 위치에서 터집니다
pragma pack(1)이 PKCS#11 function table에 하는 일
CK_FUNCTION_LIST 안의 모든 function pointer를 이동시킵니다. table은 two-byte CK_VERSION으로 시작하므로 natural alignment에서는 compiler가 version 뒤에 six byte padding을 넣어 첫 function pointer가 offset 8에 놓입니다. byte packing에서는 padding이 없어 offset 2에 놓입니다. 이후 entry도 모두 같은 displacement를 물려받으므로 packing mistake는 one-field problem이 아니라 whole-table problem입니다. trap은 PKCS#11 header가 Windows에서만 #pragma pack(1)을 적용한다는 점입니다. module의 차이가 아니라 platform difference이며 같은 vendor library라도 어느 host에서 왔는지에 따라 두 build가 다르게 동작합니다. field가 전부 pointer-width인 구조체가 대부분이라 packing이 아무것도 바꾸지 않는 경우도 많습니다. 따라서 CK_SLOT_INFO만 건드리는 naive test는 table 아래가 six byte 밀려 있어도 기쁘게 pass합니다
{$IFDEF FPC}
{$IFDEF MSWINDOWS}{$PACKRECORDS 1}{$ELSE}{$PACKRECORDS C}{$ENDIF}
{$ELSE}
{$A1}
{$ENDIF}
CK_VERSION = record
Major: Byte;
Minor: Byte;
end;
CK_ATTRIBUTE = record
AttrType: CK_ATTRIBUTE_TYPE;
Value: Pointer;
ValueLen: CK_ULONG;
end;
CK_FUNCTION_LIST = record
Version: CK_VERSION; // two bytes이며 table이 이동하는 이유입니다
C_Initialize: Pointer; // packed offset 2, aligned offset 8
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... table은 fixed order입니다. 이 backend가 호출하는 곳에 도달하려면
// C_Sign까지의 prefix를 선언하면 충분합니다
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
이 block에서 보이는 것보다 중요한 세 가지가 있습니다. {$PACKRECORDS C}는 "directive 없음"과 같지 않습니다. Free Pascal에 platform C compiler의 alignment rule을 따르라고 말하며 Linux와 macOS에서 필요한 contract가 바로 이것입니다. Delphi branch가 unconditional {$A1}인 이유는 PDFiumPas의 Delphi build가 Windows를 target으로 하기 때문이고 FPC가 Linux와 macOS build를 담당합니다. 끝의 restore line도 장식이 아닙니다. unit을 packed 상태로 두면 이 뒤에 선언된 모든 record의 layout이 조용히 바뀌며 이는 PDFium component binding을 ABI와 memory-safety fault에 맞게 harden하려는 작업이 제거하려는 action-at-a-distance defect입니다
Pkcs11AbiLayout으로 layout을 assertion으로 바꾸기
Pkcs11AbiLayout은 build가 실제로 resolve한 layout을 ulong=4 attr=16 pss=12 table=2 형태의 assertable string으로 보고합니다. 64-bit Windows build는 정확히 이를 보고해야 하고 LP64 target은 ulong=8 attr=24 pss=24 table=8을 보고해야 합니다. 다른 값이면 function table을 통한 call이 잘못된 slot에 도착한다는 뜻이며 이 function은 comment가 그렇다고 주장하게 두지 않고 unit test가 소리 내어 말할 수 있도록 존재합니다
function Pkcs11AbiLayout: string;
var
Table: CK_FUNCTION_LIST;
begin
Result := 'ulong=' + IntToStr(SizeOf(CK_ULONG)) +
' attr=' + IntToStr(SizeOf(CK_ATTRIBUTE)) +
' pss=' + IntToStr(SizeOf(CK_RSA_PKCS_PSS_PARAMS)) +
' table=' + IntToStr(NativeUInt(@Table.C_Initialize) - NativeUInt(@Table));
end;
// load 시 C_GetFunctionList가 table을 돌려준 뒤:
// implausible version이나 nil entry point는 record가 wrong packing 또는
// CK_ULONG width로 layout되었다는 뜻이므로 module을 거부합니다
if (FList^.Version.Major < 2) or (FList^.Version.Major > 3) or
not Assigned(FList^.C_Initialize) or not Assigned(FList^.C_GetSlotList) or
not Assigned(FList^.C_Sign) then
begin
FList := nil;
Exit;
end;
네 숫자는 임의가 아닙니다. attr는 CK_ULONG, pointer, CK_ULONG을 담는 CK_ATTRIBUTE의 size입니다. Windows x64에서 packed면 4 + 8 + 4이고 LP64에서 aligned면 8 + 8 + 8입니다. pss는 CK_ULONG field 세 개를 가진 CK_RSA_PKCS_PSS_PARAMS이므로 12 또는 24입니다. table은 첫 function pointer의 offset이며 packing mistake를 가장 먼저 잡는 값입니다. Delphi test case는 {$IFDEF MSWINDOWS} 아래에서 string을 assert하고 Lazarus suite도 같은 것을 assert합니다. equality check 하나가 없으면 C header와 Pascal record를 나란히 놓고 자신을 믿어야만 검증할 수 있는 layout을 다룹니다. load-time check는 같은 idea의 두 번째 절반입니다. PDFiumPas는 C_GetFunctionList만 GetProcAddress 또는 GetProcedureAddress로 이름을 resolve하고 다른 모든 entry point는 그 call이 돌려준 table에서 가져옵니다. OASIS PKCS #11 base specification이 module에 접근하도록 의도한 방식이며 vendor별 symbol naming도 피합니다. 그 다음 돌아온 값을 sanity-check합니다. major version이 2부터 3 밖에 있거나 C_Initialize, C_GetSlotList, C_Sign 중 하나가 nil이면 record가 misaligned된 것이므로 module을 call하지 않고 버립니다
table을 통한 signing: mechanism, DigestInfo와 두 번의 C_Sign
layout이 맞으면 signing 작업은 작습니다. PDFiumPas가 backend에 요구하는 ICmsSigner contract는 method 다섯 개이며 그중 네 개는 OID와 signer identifier만 반환하기 때문입니다. 실제로 작업하는 것은 SignSignedAttrsDigest 하나이며 signed attribute의 32-byte SHA-256 digest를 받아 signature byte를 반환합니다. CMS assembly, ASN.1, RFC 3161 timestamping, DSS/LTV는 모두 platform-independent이고 이미 처리되어 있습니다. 이는 HSM 또는 cloud key service에 대한 remote PAdES signing session이 같은 seam에 연결되는 것과 같은 역할 분담입니다. 세 mechanism 세부 사항을 건너뛰면 verification 실패 비용을 냅니다. CKM_RSA_PKCS는 PKCS#1 v1.5 padding을 적용하지만 DigestInfo를 만들지 않으므로 caller가 RFC 8017의 19-byte SHA-256 DigestInfo prefix를 직접 앞에 붙여야 합니다. bare digest를 token에 넘기면 well-formed지만 잘못된 것에 대한 signature가 나옵니다. CKM_RSA_PKCS_PSS와 CKM_ECDSA는 digest를 받은 그대로 처리하지만 CKM_ECDSA는 raw r||s pair로 답하고 CMS는 RFC 3279 §2.2.3의 ECDSA-Sig-Value SEQUENCE를 필요로 하므로 PDFiumPas가 convert합니다. 그리고 C_Sign은 의도적으로 두 pass입니다. 먼저 nil buffer로 token에 signature length를 물은 다음 그 크기의 buffer로 다시 호출합니다
var
Options: TPdfPkcs11Options;
Provider: IPdfPkcs11SignerProvider;
Slot: TPdfPkcs11Slot;
begin
Options := TPdfPkcs11Options.Default;
Options.ModulePath := '/usr/lib/softhsm/libsofthsm2.so';
Options.Pin := ReadOperatorPin;
Options.CertificateLabel := 'Signing Certificate';
if not Pkcs11ModuleAvailable(Options.ModulePath) then
raise Exception.Create('No usable PKCS#11 module at ' + Options.ModulePath);
// 새 platform에서 token이 이상하게 행동하면 무엇보다 먼저 이를 log합니다
Writeln('PKCS#11 ABI layout: ' + Pkcs11AbiLayout);
Provider := ConfigurePkcs11SignerProvider(Options);
for Slot in Provider.EnumerateSlots do
if Slot.TokenPresent then
Writeln(Slot.SlotID, ' ', Slot.TokenLabel);
end;
첫 token을 보기 전에 알아 둘 작은 사항도 있습니다. module은 path로 cache됩니다. process당 module마다 C_Initialize는 한 번이기 때문에 반복 call은 CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190)를 반환하며 PDFiumPas는 host의 다른 부분이 같은 library를 이미 initialize했다는 가정 아래 이를 success로 취급합니다. slot description이나 token label 같은 token string은 NUL-terminated가 아니라 blank-padded fixed-width field이므로 끝에서 trim해야 합니다. 그리고 CKO_CERTIFICATE는 2가 아니라 1입니다. 0은 CKO_DATA이고 2는 CKO_PUBLIC_KEY입니다. 이 constant를 기억에서 써 버리면 error 하나 없이 empty search result가 나옵니다
검증되는 것과 보장이 끝나는 곳
boundary를 분명히 해야 합니다. feature description이 암시하는 것보다 좁습니다. 현재 PDFiumPas에서 검증되는 것은 양쪽 branch의 ABI layout이 C header와 field 단위로 일치하고 absent 또는 unloadable module이 crash가 아니라 보고된 failure로 degrade하며 Delphi와 FPC toolchain 모두 unit을 build한다는 점입니다. 실제 token path인 C_Login, object search, hardware를 상대로 하는 C_Sign은 개발 host에 PKCS#11 module이 전혀 설치되어 있지 않아 exercise되지 않았습니다. physical token을 꽂기 전에 SoftHSM2를 먼저 bring up하고 Pkcs11AbiLayout을 확인하세요. ABI problem과 token problem을 동시에 진단할 필요가 없게 됩니다. 비대칭도 하나 더 이름 붙일 가치가 있습니다. signing side는 이제 cross-platform이지만 verification side는 아닙니다. PDFiumPas 안의 CMS verification은 여전히 {$IFDEF MSWINDOWS}로 guard되어 다른 곳에서는 pcsUnsupported를 반환하고 signer backend와 동등한 provider injection point도 없습니다. 따라서 Linux service는 token-held key로 PAdES B-B signature를 만들 수 있지만 아직 같은 machine에서 자체 output을 검사할 수 없습니다. 이 gap이 메워질 때까지 verification step을 Windows나 external validator에 배치하세요
교훈은 PKCS#11을 넘어 일반화됩니다. conditionally packed C struct를 mirror하는 Pascal record에는 세 가지가 필요합니다. platform에 따라 바뀌는 scalar의 width 결정을 정확히 한 곳에 두는 conditional alias 하나, declaration을 감싸고 끝에서 복원하는 packing directive, resolved layout을 test가 assert할 수 있는 값으로 보고하는 runtime function입니다. struct가 header와 일치한다고 주장하는 comment는 아무 가치가 없으며 startup에서 출력한 SizeOf와 field offset은 큰 가치가 있습니다. PKCS#11 backend, CNG backend와 나머지 signing stack은 PDFium Component for Delphi and C++Builder에 포함되어 있으며 ABI plumbing이 이미 조건에 맞게 구성되어 여러분의 code는 token 쪽 문제에 집중할 수 있습니다