技術記事

DelphiのPKCS#11:CK_ULONGとpacking trap

PDFiumPasはWindows、Linux、macOSでPKCS#11 tokenを通じてPAdES documentに署名します。bindingが動くかどうかを決めるplatform factは2つです。CK_ULONGはCのunsigned longなのでWindowsでは4 byte、LinuxとmacOSでは8 byteです。またPKCS#11 headerはWindowsでだけ#pragma pack(1)を適用し、function table内のすべてのpointerを移動させます。どちらか1つを間違えてもmoduleはloadし、callはreturnし、戻るnumberだけがgarbageになります。予想すべきbugの形はこれです。linker errorは出ません。何もlinkせず、実行時にpathから.so.dylib.dllをopenし、surface全体をcastしてcallするfunction pointerのstructだからです。compilerは反対側のC headerがどう見えていたかを知りません。すべてのmismatchはcrashするまでsilentです

PKCS#11 bindingがclean errorではなくrandom CKR codeで失敗する理由

ABI mismatchはerror conditionを生成せず、wrong addressまたはwrong offsetを生成します。tokenはその結果が指す質問へ、律儀に答えます。record declarationとmoduleの間には不一致を検出するlayerがありません。failure modeは2つに分かれます。packingを間違えると、C_GetSlotListとして読むslotには1つのpointerの6 byteと次のpointerの2 byteが入ります。callingするとunmapped memoryへjumpするか、さらに悪い場合は別のfunctionの途中へjumpします。これがaccess violationです。CK_ULONGのwidthを間違えるとaddressは正しくdataが壊れます。LP64 moduleが8 byteを書き込むのにvar Count: CK_ULONGを4 byte幅で宣言すると、stack frameの次の4 byteを静かに上書きします。またCK_ATTRIBUTE templateでValueLenがwrong offsetにあると、moduleはValue pointerからlength fieldを読みます。tokenは、尋ねてもいない質問に対してCKR_BUFFER_TOO_SMALLCKR_ATTRIBUTE_VALUE_INVALIDという完全に正当なcodeを返します。そのcodeを見て、token configurationを何時間も調べることになります。bugはtype declarationの4行上です

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全体で最も影響が大きい1行です。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;

それらすべてをLongWordUInt64へ直接aliasせずCK_ULONGへaliasすることが、このexerciseの要点です。conditionalを1箇所だけにできます。どれか1つをconcrete typeで書くと、future portが踏むlandmineを置くことになり、しかも踏むのは忘れた場所です

pragma pack(1)がPKCS#11 function tableにすること

構造体の先頭に2-byteのCK_VERSIONがあるため、CK_FUNCTION_LIST内のすべてのfunction pointerを移動させます。natural alignmentならcompilerはそのversionの後ろへ6 byte paddingを挿入し、first function pointerはoffset 8になります。byte packingならpaddingがなくoffset 2です。後続のentryはすべて同じdisplacementを受けるため、packing mistakeはfield 1つではなくtable全体の問題です。trapはPKCS#11 headerがWindowsでだけ#pragma pack(1)を適用することです。これはmoduleの差ではなくplatformの差です。同じvendor libraryの2 buildでも、どのhostから来たかによってこの点が異なります。なお、fieldsがすべてpointer-widthのstructureではpackingは変化しません。大部分がそうなので、CK_SLOT_INFOだけを触るnaive testは、下のtableが6 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;      // 2 byteで、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。prefixをC_Signまで宣言すれば
    // backendがcallするすべてへ到達できる
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}

このblockで見た目以上に重要なのは3つです。{$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が静かに変わります。まさにABIとmemory-safety faultに対してPDFium component bindingをhardeningする方法がなくそうとするaction-at-a-distance defectです

Pkcs11AbiLayout:layoutをassertionへ変える

Pkcs11AbiLayoutは、buildが実際にresolveしたlayoutを、ulong=4 attr=16 pss=12 table=2のようなassert可能なstringで報告します。64-bit Windows buildは正確にこれを報告し、LP64 targetはulong=8 attr=24 pss=24 table=8を報告しなければなりません。それ以外ならfunction table経由のcallはwrong 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 timeにC_GetFunctionListからtableを受け取った後:
// implausibleなversionやnil entry pointはwrong packingまたはCK_ULONG widthを
// 意味するため、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;

4つのnumberは任意ではありません。attrはCK_ULONG、pointer、CK_ULONGを持つCK_ATTRIBUTEのsizeです。Windows x64のpackedでは4 + 8 + 4、LP64のalignedでは8 + 8 + 8です。pssは3つのCK_ULONG fieldを持つCK_RSA_PKCS_PSS_PARAMSで、12または24です。tableはfirst function pointerのoffsetで、packing mistakeを最初に捕捉するvalueです。Delphi test caseは{$IFDEF MSWINDOWS}下でstringをassertし、Lazarus suiteも同じことをします。C headerとPascal recordを横に並べて自分を信じる以外に検証方法がなかったlayoutを、equality check 1つでカバーできます。load-time checkは同じideaの後半です。PDFiumPasはGetProcAddressまたはGetProcedureAddressでnameからC_GetFunctionListだけをresolveし、そのcallが返すtableから残りのすべてのentry pointを取ります。OASIS PKCS #11 base specificationがmoduleへ到達する方法であり、vendorごとのsymbol namingを避けます。その後sanity-checkします。major versionが2から3の外側、またはC_InitializeC_GetSlotListC_Signがnilならrecordはmisalignedです。moduleはcallせずdropします

tableを通じたsigning:mechanism、DigestInfo、2 passのC_Sign

layoutが正しければsigning workは小さいものです。PDFiumPasがbackendに求めるICmsSigner contractは5 methodで、そのうち4つは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へplugできるのと同じ役割分担です。mechanismについて3つ知らないとverificationに失敗します。CKM_RSA_PKCSはPKCS#1 v1.5 paddingを適用しますがDigestInfoを構築しません。そのためcaller自身がRFC 8017の19-byte SHA-256 DigestInfo prefixをprependします。bare digestをtokenへ渡すと、wrong thingへのwell-formed signatureが返ります。CKM_RSA_PKCS_PSSCKM_ECDSAはdigestを提示されたまま受け取りますが、CKM_ECDSAはraw r||s pairを返します。CMSにはRFC 3279 §2.2.3のECDSA-Sig-Value SEQUENCEが必要なので、PDFiumPasがconvertします。そしてC_Signは意図的にtwo-passです。nil bufferでcallしてtokenへsignature lengthを尋ね、そのsizeのbufferでもう一度callします

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);
  // tokenが新しいplatformで問題を起こしたら、何より先にこれを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されます。C_Initializeはprocessごとmoduleごとに1回だからです。repeat callはCKR_CRYPTOKI_ALREADY_INITIALIZED(0x00000190)を返しますが、PDFiumPasは同じlibraryをhostの別部分がすでにinitializeしたと考え、successとして扱います。slot descriptionやtoken labelのようなtoken stringはNUL-terminatedではなく、blank-padded fixed-width fieldなのでtailからtrimする必要があります。そしてCKO_CERTIFICATEは1で、2ではありません。0はCKO_DATA、2はCKO_PUBLIC_KEYです。このconstantを記憶で書くと、errorなしにempty search resultが返るというmistakeになります

verificationされるものと保証が止まる場所

boundaryを明確にしてください。feature descriptionが示すより狭いからです。今日のPDFiumPasでverifyされるのは、両branchでABI layoutがC headerとfieldごとに一致すること、absentまたはunloadable moduleがcrashではなくreported failureへdegradeすること、DelphiとFPCの両toolchainがunitをbuildすることです。real token path、C_Login、object search、hardwareに対するC_Signはexerciseされていません。development hostにはPKCS#11 moduleがまったくinstallされていないためです。physical tokenをplugする前にSoftHSM2をbring upし、Pkcs11AbiLayoutをconfirmしてください。そうすればABI problemとtoken problemを同時に診断せずに済みます。もう1つasymmetryがあります。signing sideはcross-platformになりましたが、verification sideはそうではありません。PDFiumPasのCMS verificationはなお{$IFDEF MSWINDOWS}でguardされ、他platformではpcsUnsupportedを返します。signer backendに相当するprovider injection pointもありません。そのためLinux serviceはtoken-held keyでPAdES B-B signatureを生成できますが、同じmachine上で自分のoutputをcheckすることはまだできません。このgapが閉じるまではverificationをWindowsかexternal validatorへ計画してください

教訓はPKCS#11を越えて一般化します。conditionally packed C structをmirrorするPascal recordには3つが必要です。platformで変わるscalar用のconditional aliasを1つ、width decisionがちょうど1箇所に存在するようにすること。declarationをbracketし、後でrestoreするpacking directive。resolved layoutをtestがassertできるものとして報告するruntime functionです。structがheaderにmatchすると主張するcommentには価値がありません。SizeOfとstartup時にprintするfield offsetには大きな価値があります。PKCS#11 backend、CNG backend、残りのsigning stackはPDFium Component for Delphi and C++Builderに含まれています。ABI plumbingはすでにconditionedされているため、あなたのcodeはproblemのtoken側にとどまれます