مقاله فنی

امضای PAdES در دلفی با یک هویت Keychain در macOS

PDFium VCL سندهای PAdES را با کلید خصوصی‌ای که در macOS Keychain زندگی می‌کند از طریق بک‌اندی امضا می‌کند که هر سیمبول Security و CoreFoundation را در ران‌تایم با dlopen و dlsym resolve می‌کند. هیچ چیزی link-time باند نمی‌شود، یعنی نام سیمبولی که غلط تایپ شده به‌شکل KeychainAvailable که False برمی‌گرداند و KeychainMissingSymbols که مقصر را نام می‌برد خودش را نشان می‌دهد، نه به‌شکل خطای لینکر یا یک کرش

این انتخاب از یک محدودیت ناخوشایند بیرون آمده بود، و راهی که با آن برخورد شد قابل تعمیم است. یونیت روی ماشینی نوشته شد که macOS SDK نداشت، پس هر نام سیمبول framework و هر ثابت از مستندات آمده بود و هیچ‌کدام را نمی‌شد با یک header چک کرد. پاسخ غلط به چنین وضعیتی این است که کد را با دقت بنویسید و دعا کنید. پاسخ درست این است که ترتیبی بدهید تا اشتباهات محتوم در قابل‌مکان‌یابی‌ترین شکل ممکن خودشان را اعلام کنند

چرا بایندینگ داینامیک حتی روی خود پلتفرم هدف انتخاب درستی است

چون یک رده شکستِ برنامه‌خوابان را به رده‌ای از شکست تبدیل می‌کند که خودش را گزارش می‌کند. ارجاع frameworkی که statically لینک شده و غلط باشد روی هدف در زمان لینک شکست می‌خورد و هیچ‌جای دیگر هم لینک نمی‌شود. نمونه باندشده داینامیک که غلط باشد یک بک‌اند در دسترس‌ناشدنی و فهرستی از نام‌های resolve‌نشده تولید می‌کند، و اولین run روی Mac سؤال را از اینکه چرا این در دسترس نیست به یک خط که غلط تایپی را نام می‌برد تبدیل می‌کند

یک منفعت دوم هم هست که به‌جای یک بار هر روز سود می‌دهد. چون یونیت هیچ frameworkای را لینک نمی‌کند، روی هر پلتفرمی کامپایل می‌شود، پس بیلد معمول ویندوز سینتکس، تایپ‌ها و uses clause آن را پیوسته چک می‌کند. یونیتی که فقط روی پلتفرمی کامپایل می‌شود که هیچ‌کس در تیم ندارد یونیتی است که هیچ کامپایلری نگاهش نمی‌کند، و با هر refactor هر تایپ مشترک بی‌سروصدا می‌پوسد

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // نصب به‌عنوان بک‌اند امضای PAdES
  ConfigureKeychainCmsVerifier;      // و به‌عنوان بک‌اند وریفیکیشن

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1، هر حالت حروف
  Options.PaddingScheme := psRsaPss;
end;

دو جنس سیمبول اکسپورت‌شده، دو راه خواندن

این گیج‌کننده‌ترین جزئیات کل بایندینگ است، و برعکس‌گرفتنش تمیز کامپایل می‌شود و در ران‌تایم شکست می‌خورد. CoreFoundation و Security از طریق همان فراخوانی dlsym دو چیز از جنس‌های اساساً متفاوت اکسپورت می‌کنند، و کد باید بداند کدام کدام است

ثابت‌های نام‌دار مثل کلیدهای کلاس آیتم keychain و singletonهای بولی CoreFoundation متغیرهایی اکسپورت‌شده‌اند که محتوایشان همان CFStringRef یا CFBooleanRef مورد نظر شماست. dlsym آدرس آن متغیر را برمی‌گرداند، پس برای رسیدن به مقدار باید یک بار dereference کنید. ساختارهای callback-table مثل callbackهای کلید و مقدار دیکشنری ساختارهایی اکسپورت‌شده‌اند، و dlsym آدرس خود ساختار را برمی‌گرداند، که دقیقاً همان اشاره‌گری است که تابع ساختن دیکشنری انتظارش را دارد. همان یکی را dereference کنید و اولین کلمه ماشینی ساختار را پاس می‌دهید انگار اشاره‌گر باشد

هیچ‌کدام از این دو اشتباه خطای کامپایل تولید نمی‌کند و هیچ‌کدام خطای ران‌تایم روشنی هم تولید نمی‌کند. یک اشاره‌گر آشغال می‌گیرید که جایی پایین‌دست شکست می‌خورد. راهی که این تمایز را غیرقابل اشتباه می‌کند این است که از بسپردنش دست بکشید: دو تابع helper، یکی که باند و dereference می‌کند و یکی که فقط باند می‌کند، تا محل فراخوانی اعلام کند چه جنسی از سیمبول می‌خواهد و helper بقیه‌اش را تضمین کند

نمودار بک‌اند macOS Keychain در PDFium VCL که سیمبول‌های Security و CoreFoundation را از طریق dlsym resolve می‌کند: kSecClass یک متغیر اکسپورت‌شده است که BindConstant برای رسیدن به مقدار CFStringRef یک بار dereference می‌کند، در حالی که kCFTypeDictionaryKeyCallBacks یک ساختار اکسپورت‌شده است که BindStruct با آدرس پاس می‌دهد، و قاطی‌کردن این دو قاعده اشاره‌گرهای آشغال در پایین‌دست تولید می‌کند
یک فراخوانی dlsym دو چیز از جنس‌های اساساً متفاوت برمی‌گرداند: آدرس متغیری که CFTypeRef را نگه می‌دارد و آدرس یک ساختار callback. دو helper تصمیم dereference-کردن-یا-نه را در محل بایندینگ می‌گیرند نه در حافظه
// متغیر اکسپورت‌شده: dlsym آدرس متغیری را می‌دهد که CFTypeRef را نگه
// می‌دارد، پس یک بار dereference کنید
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// ساختار اکسپورت‌شده: dlsym آدرس خودِ ساختار را می‌دهد که همان چیزی
// است که API می‌خواهد. dereference نکنید
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

چرا امضای RSA-PSS به دو fallback جداگانه نیاز دارد؟

چون الگوریتم به دو راه مستقل می‌تواند غایب باشد، و فقط یکی از آنها سؤال نسخه است. ثابت الگوریتم digest-signing PSS از macOS 10.13 پدید آمد، پس روی سیستم قدیمی‌تر سیمبول اصلاً آنجا نیست و بایندینگ nil می‌گیرد. آن همان چک نسخه است. جدا از آن، روی سیستمی که ثابت وجود دارد، یک کلید خاص هنوز می‌تواند سرپیچی کند، و framework آن سؤال را از طریق SecKeyIsAlgorithmSupported برای همان کلید جواب می‌دهد. یک کلید سخت‌افزارپشتیبان یا کلیدی با attributeهای محدودکننده می‌تواند PSS را رد کند در حالی که کلید نرم‌افزاری همان ماشین قبولش می‌کند

هر دو مسیر باید به یک fallback برسند: سوییچ به PKCS#1 v1.5. و بخش حیاتی این است که fallback باید identifier الگوریتمی را که در ساختار CMS نوشته می‌شود هم عوض کند نه فقط فراخوانی امضا را. تولید کردن identifier الگوریتم PSS در حالی که عملاً امضای v1.5 تولید می‌شود سندی می‌دهد که هر verifierای همان اول رد می‌کند، که اکیداً بدتر از گزارش پشتیبانی‌نشدن PSS است. downgrade قابل قبول است، عدم‌تطابق بین چیزی که اعلان می‌کنید و کاری که کرده‌اید قابل قبول نیست، و این یک قاعده کلی برای کد امضا است نه یک خُلق‌وخوی macOS. پیامدهای سطح امضا در امضای PDFها با PAdES B-B چیده شده

زنجیره تصمیمی که نشان می‌دهد چرا امضای RSA-PSS در بک‌اند Keychain در PDFium VCL به دو fallback مستقل نیاز دارد: dlsym برای ثابت digest-signing روی نسخه‌های macOS قبل از 10.13 برمی‌گرداند nil، SecKeyIsAlgorithmSupported می‌تواند یک کلید سخت‌افزارپشتیبان را رد کند، و هر دو گیت به همان downgrade یکسان به سمت PKCS#1 v1.5 می‌ریزند که identifier الگوریتم CMS آن هم باید همراهش عوض شود
PSS به دو نحو می‌تواند در دسترس نباشد، یک بار به‌ازای هر نسخه macOS و یک بار به‌ازای هر کلید، و فقط گیت نسخه سؤالی سیستمی است. هر دو گیت به همان downgrade یکسان به سمت v1.5 می‌ریزند و identifier CMS هم دنبالش می‌رود

انکودینگ امضای ECDSA و وارونگی‌ای که ارزش ذکر دارد

مسیر منحنی بیضوی روی macOS اصلاً به تبدیل نیاز ندارد، و این برعکس چیزی است که یک بایندینگ PKCS#11 الزام می‌کند. الگوریتم digest-signing فریم‌ورک Security برای ECDSA امضا را از قبل به‌شکل X9.62 DER برمی‌گرداند، که دقیقاً همان چیزی است که CMS می‌خواهد. یک توکن PKCS#11 به‌جایش جفت خام P1363 با عرض ثابت را برمی‌گرداند، که باید قبل از رفتنش به داخل یک ساختار امضا دوباره انکود شود

پس دو بک‌اند که یک اینترفیس را پیاده می‌کنند برای یک الگوریتم واحد رفتار متضاد می‌خواهند، و هیچ‌کدام غلط نیست. این دقیقاً از آن تفاوت‌هاست که یک abstraction باید جذبش کند نه نشان دادنش: لایه PAdES از provider می‌خواهد امضا کند و قراردادهای انکودینگ داخل provider می‌مانند. اگر به بالا نشت کنند، هر caller به یک conditional به‌ازای هر بک‌اند دچار می‌شود. همان شکل در قصه امضای ریموت که در نشست‌های امضای PAdES ریموت روی یک HSM توصیف شده هم می‌آید

مقایسه انکودینگ امضای ECDSA بین دو بک‌اند signer پایدس در PDFium VCL: فریم‌ورک Security در macOS Keychain X9.62 DER آماده را برمی‌گرداند که CMS با صفر تبدیل قبولش می‌کند، در حالی که توکن PKCS#11 جفت خام P1363 با عرض ثابت را برمی‌گرداند که باید دوباره انکود شود، پس ResolvePadesSigner قراردادهای انکودینگ را داخل provider نگه می‌دارد
همان اینترفیس ECDSA به‌ازای هر بک‌اند رفتار متضاد می‌خواهد: Security تحویل‌دهنده DER آماده است در حالی که توکن PKCS#11 تحویل‌دهنده P1363 خام است، پس تبدیل داخل provider زندگی می‌کند و callerها هیچ‌وقت یک conditional به‌ازای هر بک‌اند نمی‌بینند
// اینترفیس provider روی هر پلتفرمی یکی است، پس انتخاب یک تصمیم استارتاپ
// است نه یک تصمیم به‌ازای هر فراخوانی
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // provider CNG ویندوز را یونیت پلتفرم نصب می‌کند
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// از اینجا به بعد کد امضا platform-neutral است
Signer := ResolvePadesSigner(Options);

قواعد شمارش مرجع که سه خط از هم فاصله دارند

مدیریت حافظه Core Foundation از قراردادهای نام‌گذاری پیروی می‌کند، و تله اینجاست که توابعی با قراردادهای متفاوت کنار هم در همان بلوک کوتاه می‌آیند. تابعی که یک گواهی را از یک trust object می‌گیرد ارجاع قرضی برمی‌گرداند که نباید آزاد شود. توابعی که گواهی signer را کپی می‌کنند یا داده‌اش را کپی می‌کنند ارجاع‌های متعلق برمی‌گردانند که باید آزاد شوند. سه فراخوانی پشت سر هم، دو قاعده مالکیت، و آزاد کردن آن قرضی همان‌جا خطا نمی‌دهد. یک retain count را خراب می‌کند و بعداً چیزی بی‌ربط را می‌اندازد

درمانش این است که فعل داخل نام هر تابع framework را قبل از نوشتن cleanup، هر بار، بدون استثنا بخوانید. همان کار چک‌کردن اینکه یک API کپی برمی‌گرداند یا view است، فقط در دنیای CoreFoundation، و هزینه اشتباه کردن یک کرش متناوب است نه یک پیام خطا

این بک‌اند چه چیزی را ادعا نمی‌کند

تا زمان نوشتن این متن هرگز روی macOS اجرا نشده، و گفتن همین به‌صراحت مفیدتر از یک اطمینان ضمنی است. آنچه اثبات‌پذیر درست است باریک‌تر است و همچنان ارزش دارد: یونیت روی ویندوز به‌عنوان بخشی از بیلد روزانه کامپایل می‌شود، هر سیمبول framework در ران‌تایم با نام باند می‌شود و شکست‌ها شمرده می‌شوند، و منطق انتخاب الگوریتم شامل هر دو fallback مربوط به PSS پاسکال معمولی است که می‌شود بازبینی و درباره‌اش استدلال کرد. اولین run روی Mac یا کار می‌کند یا فهرستی از نام‌ها برای تعمیر تولید می‌کند

قرینه وریفیکیشن آن، که از دیکدر CMS سطح بالاتر استفاده می‌کند به‌جای سرهم‌کردن دستی ساختار CMS، در وریفای امضاهای PDF روی macOS با SecTrust پوشش داده شده، و همان زیرساخت بایندینگ و همان رویکرد تشخیصی را به اشتراک می‌گذارد

ایده قابل‌حمل از این ماجرا درباره جای‌گذاری ریسک است نه درباره macOS. وقتی مجبورید کدی را در برابر اینترفیسی بنویسید که نمی‌توانید verifyش کنید، سازه‌ای را انتخاب کنید که اشتباهات در آن ارزان‌ترین قابل‌مکان‌یابی باشند. بایندینگ داینامیک با فهرست صریح نام‌های resolve‌نشده بیست فرض تأییدناپذیر را به یک خط تشخیصی تبدیل می‌کند. هر دو بک‌اند به‌شکل سورس همراه PDFium Delphi component عرضه می‌شوند، پس اگر نام سیمبولی واقعاً لازم باشد اصلاح شود، یک تغییر یک‌خطی در درخت خودتان است نه یک تیکت پشتیبانی