Tekninen artikkeli

CMS-allekirjoitusten jäsennys Delphissä: DER-rajat ja OID

PDF Library for Delphi (PDFlibPas) poimii PDF-allekirjoituksen sisällä olevat sertifikaatit puhtaalla DER-kävelyllä /Contentsissa säilytetyn CMS SignedData -rakenteen läpi ilman CryptoAPIa. Versiosta v3.539.10 alkaen jokainen sisäkkäinen luku rajautuu vanhempaan alkioonsa, CMS:n jälkeinen nollatäyte katkaistaan siinä kohdassa, jonka CMS ilmoittaa pituudekseen, ja objektitunnisteet koodaavat yhdistetyn ensimmäisen alitunnisteensa base-128-koodauksella. Rajaussääntö ja OID-korjaus korvasivat molemmat koodia, joka tuotti vääriä vastauksia nostamatta virhettä, ja täytteen karsiminen pitää tiukemman lukijan hylkäämästä oikeita allekirjoituksia

Lukupuoli on tärkeämpi kuin näyttää. Pitkän aikavälin validointityökalun on poimittava allekirjoittajan sertifikaatti ja sen myöntäjät olemassa olevasta allekirjoituksesta ennen kuin se voi hakea mitätöintitietoja, auditointiraportin on kerrottava kuka allekirjoitti, ja Linuxille rakennettu Lazarus-versio ei nojaa mihinkään Windows-viestifunktioon. Jäsennin tuossa asemassa kaatuu harvoin huonoon syötteeseen. Kipeä vikatila on sertifikaattilukumäärä, johon kuuluu naapurille kuuluvia tavuja, allekirjoittajan täsmäytys väärää kenttää vasten tai OID, joka hiljaa muuttuu toiseksi OID:ksi. Sen varaan rakennettu allekirjoitusputki raportoi itsevarmasti täyttä roskaa

Allekirjoittajan sertifikaattien lukeminen allekirjoitetusta PDF:stä

Viisi TPDFlib-metodia kattaa lukupuolen, ja kaikille niistä annetaan InputFile, Password, FieldName: jokainen kutsu avaa tiedoston vain luku -tilassa, vastaa ja sulkee sen taas. GetSignatureEmbeddedCertificateCount ja GetSignatureEmbeddedCertificateDER luettelevat sertifikaattijoukon koodausjärjestyksessä, GetSignatureSignerCertificateDER palauttaa sertifikaatin, joka tuotti kyseisen SignerInfon, ja GetSignatureCertificateChainLength / GetSignatureCertificateChainDER kävelevät kyseisestä allekirjoittajasta kohti kauimmaista myöntäjää, jonka allekirjoitus itse mukanaan kantaa. Indeksit alkavat nollasta. Pidä tulokset AnsiStringissä, ja juuri siksi kirjasto palauttaa ne sellaisena: DER-lohko, joka kulkee stringin tai TStringsin kautta, menee merkistömuunnoksen läpi ja palaa turmeltuneena

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

Tuossa tulosteessa on kaksi kohtaa, jotka vaativat huomiota. Luku 0 ei ole diagnoosi: puuttuva kenttä, väärä salasana, ei-DER-lohko ja SignedData, joka jättää valinnaisen sertifikaattijoukon kokonaan pois, palautuvat kaikki arvona 0 tai tyhjänä merkkijonona, joten kirjaa kentän nimi luvun viereen. Eikä virhe ole myöskään se, että ketju päättyy ennen self-issued-sertifikaattia. Ketjun rakentaja käyttää vain allekirjoitukseen upotettuja sertifikaatteja, joten loput myöntäjät on noudettava osoitteista, jotka GetCertificateIssuerURLs raportoi

Kuinka suuri osa /Contentsista oikeasti on CMS:ää?

CMS:ään kuuluu vain se etuliite, jonka uloin SEQUENCE ilmoittaa, ja PLTrimCMSPadding katkaisee kaiken sen jälkeen. Allekirjoittaja varaa /Contents-heksamerkkijonon ennen kuin CMS on ylipäätään olemassa, koska ISO 32000-1 §12.8.1:n mukainen /ByteRange on kiinnitettävä ensin, joten slotti varataan reilulla marginaalilla ja käyttämättä jäävä häntä on nollia. PLTrimCMSPadding lukee ensimmäisen TLV:n, vaatii tagia $30 ja palauttaa tavut kyseisen alkion loppuun saakka; kaikki, mikä ei ala well-formed-SEQUENCElla, palautuu tyhjänä. Ylin taso on ainoa paikka, jossa häntätavut ovat laillisia, ja erottelu ratkaisee seuraavassa luvussa: tiukka "alkion on kulutettava koko puskuri" -sääntö hylkäisi jokaisen oikean maailman allekirjoituksen, kun taas löyhä sääntö jokaisella syvyydellä päästäisi sisäkkäiset kentät lukemaan tavuja, jotka eivät ole niiden omia

PDFlibPas PLTrimCMSPadding lukee varatun /Contents-heksamerkkijonon ensimmäisen TLV:n, vaatii tagin $30 ja katkaisee nollatäytteen uloimman SEQUENCEn ilmoittamaan pituuteen, ja se palauttaa tyhjän tuloksen, kun puskuri ei ala well-formed-SEQUENCElla
Häntätavut ovat laillisia vain ylimmällä tasolla, jossa varatun slotin on pysyttävä /ByteRangen vuoksi muuttumattomana — syvemmät luvut saavat vanhempaan rajaavan säännön

Miksi DER-lukija tarvitsee vanhemman päättymiskohdan?

Sisäkkäinen alkio on kelvollinen vain, jos se päättyy vanhempansa sisälle, eikä tarkistus puskurin loppua vasten todista sitä. PDFlibASN1n matalan tason DERReadTLV rajaa jokaisen alkion koko merkkijonoon, mikä on oikea tarkistus uloimmalle objektille ja väärä kaikelle sen alapuolelle. Kuvittele SignerInfo, jonka issuerAndSerialNumber ilmoittaa 40 tavua, vaikka sen sisällä oleva issuer Name vaatii 60. Jokainen tavu on yhä puskurissa, joten puskuriin rajautuva lukija hyväksyy Namen, lukee sarjanumeron sitä seuraavasta tiivistysalgoritmista ja vertaa sitten tuota paria upotettuihin sertifikaatteihin. Ennen v3.539.10:ää CMS-kävelijä luki täsmälleen noin. Korjaus on pieni kääre, joka vie vanhemman päättymiskohdan jokaisen luvun mukana

PDFlibPas rajaa jokaisen sisäkkäisen DER-luvun vanhempaan alkioonsa: 40-tavuisen issuerAndSerialNumberin sisällä oleva 60-tavuinen issuer Name menee vanhan puskuriin rajatun DERReadTLV:n läpi, joka lukee sarjanumeron digestAlgorithmista, kun taas ReadTLVWithin hylkää jokaisen ParentEndin ohi päättyvän alkion
Puskurin sisällä on muistiturvallisuus, vanhemman sisällä on korrektius — PDFlibPas vie vanhemman loppuoffsetin jokaisen CMS-tason läpi, jotta vihamielinen pituus ei voi lainata naapurin tavuja
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // vanhemman sisällä ei ole enää mitään jäljellä: luku ei saa alkaa
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset osoittaa nyt alkion jälkeistä kohtaa; se ei saa ylittää vanhempaa
  Result := Offset <= ParentEnd;
end;

// jokainen taso kirjaa oman loppunsa ja välittää sen alaspäin:
//   OuterEnd    := ContentInfo:n loppu        (RFC 5652, osa 3)
//   ExplicitEnd := sisällön [0] EXPLICITin loppu
//   ContentEnd  := SignedData:n loppu         (RFC 5652, osa 5.1)
//   SignerEnd / InnerEnd SignerInfolle ja issuerAndSerialNumberille

Yksikkö PDFlibCMSRead vie nyt nämä loput läpi ContentInfon, [0] EXPLICIT-kääreen, SignedData-kenttien aina signerInfosia myöten, SignerIdentifierin kummassakin issuerAndSerialNumber- ja [0] subjectKeyIdentifier-muodossaan (RFC 5652 §5.3) sekä tbsCertificate-kenttien, jotka luetaan jokaisesta upotetusta sertifikaatista allekirjoittajaa täsmäytettäessä. Sertifikaattijoukon ja signerInfos-joukon sisällä joukon lopun yli juokseva alkio pysäyttää silmukan: PLExtractCMSCertificates palauttaa jo hyväksymänsä sertifikaatit eikä koskaan liimaa seuraavia crls- tai signerInfos-tavuja viimeisen päälle. Issuer-ja-sarjanumero-täsmäytys vaatii myös molemmat puolet, sillä sarjanumero on yksilöllinen vain yhden myöntäjän sisällä

Miksi 2.999.3 tuli ulos arvona 1.15.3?

OID:n kaksi ensimmäistä kaarta yhdistyvät yhdeksi alitunnisteeksi, emmekä yhdeksi tavuksi, ja se alitunniste koodataan base-128:lla kuten kaikki muut kaaretkin. X.690 §8.19.4 määrittelee sen arvoksi 40 * arc1 + arc2; aiempi DER_OID kirjoitti arvon Byte(...)-muunnoksella, mikä pitää paikkansa vain arvoon 127 asti, eli arvoon 2.47 saakka. Arvolla 2.999 summa on 1079, tavuun castaus säästää luvusta 55, ja 55 dekoodautuu arvoksi 1.15, joten tunniste nimeää hiljaisesti puun eri haaran. Arvot 128–255 epäonnistuvat toisin: ne päästävät ulos yhden tavun, jonka jatko-bitti on päällä ja joka nielee seuraavan kaaren. Useimmat PKI-tunnisteet (1.2.840..., 2.5.29..., 0.4.0...) eivät koskaan yletä rajalle, minkä vuoksi vika eli hengissä; joint-iso-itu-t-kaaret 2.48 ylöspäin ylettävät. DER_OID palvelee sekä allekirjoitettujen attribuuttien kooderia että täsmäyttäjää DERFindExtensionByOIDssa ja SignedData-sisältötyypin tarkistuksessa, joten väärä koodaus rikkoi sekä kirjoituksen että haun

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
PDFlibPas DER_OID yhdistää kaksi ensimmäistä OID-kaarta arvoksi 40 * arc1 + arc2 ja koodaa summan base-128-muotoon UInt64:ään, jolloin 2.999.3 muuttuu muotoon 06 03 88 37 03, kun taas vanha Byte-castaus säästi luvusta 55 ja dekoodasi tunnisteen hiljaa arvoksi 1.15.3
Useimmat PKI-kaaret eivät koskaan yletä rajalle, minkä vuoksi vika eli hengissä — joint-iso-itu-t-kaaret 2.48 ylöspäin tarvitsevat kaksi tavua, ja testi pitää arvot 2.47 ja 2.48 rajan kummallakin puolella

Yhdistetty arvo pidetään tarkoituksella UInt64ssa. DER_OID jäsentää kaaret Int64hin, joten laillinen toinen kaari voi olla jopa Int64.MaxValue:n suuruinen, ja arvon 80 lisääminen arc1 = 2:lle ylivuottaa etumerkillisen 64-bittisen kokonaisluvun. UInt64 kantaa arvon Int64.MaxValue + 80 kiertymättä, ja kymmenen tavun työpuskuri mahtuu ne kymmenen 7-bittistä ryhmää, joita 64-bittinen arvo tarvitsee. Säilyttämisen arvoiset testivektorit ovat rajan kummalla puolella olevat: 2.47:n on pysyttävä yhtenä tavuna, ja 2.48:n on muututtava kahdeksi

Mitä lukupuolen CMS-kävelijä takaa?

PDFlibCMSRead takaa rakenteen eikä muuta: se palauttaa tavut sieltä, minne RFC 5652 sanoo niiden kuuluvan, eikä varmista allekirjoitusta, tiivistettä eikä voimassaoloaikaa. Kävelijä hyväksyy vain DER:n, joten DERReadTLV hylkää määrittelemättömät pituudet ja monitavuiset taginumerot, ja standardia noudattamattoman allekirjoittajan BER-koodattu CMS raportoi nolla sertifikaattia osittaisen arvauksen sijaan. Attribuuttisertifikaatit ja muut CertificateChoices-vaihtoehdot ohitetaan, koska mikään myöhempi vaihe ei voi käyttää niitä. Kryptograafinen varmennus jää sen koodin vastuulle, joka sen omistaa: se alkaa kohdassa PAdES-allekirjoitus ja ByteRange-validointi Delphissä kuvatuista tavukattavuustarkistuksista ja jatkuu artikkelissa PDF:n allekirjoittamisen jälkeen muuttuneen sisällön luokittelu

Laajempi oppi pätee mihin tahansa binääriformaattiin: "puskurin sisällä" on muistiturvallisuusominaisuus, "vanhemman sisällä" on korrektiusominaisuus, ja jäsennin tarvitsee molempia. Sama ajattelu vihamielisistä pituuksista kulkee läpi kirjoituksessa Pascal-PDF-jäsennimen kovettaminen haitallisia tiedostoja vastaan. Täällä käsitellyt sertifikaattien poiminta, ketjun rakennus ja pitkän aikavälin validoinnin API:t toimitetaan mukana tuotteessa losLab PDF Library for Delphi, joka toimii Delphillä, C++Builderilla ja Lazaruksella