기술 문서

Delphi CMS 서명 파싱: DER 경계와 OID 아크

PDF Library for Delphi(PDFlibPas)는 CryptoAPI 없이 순수 DER 워크로 /Contents에 담긴 CMS SignedData를 훑어 PDF 서명 안의 인증서를 추출합니다. v3.539.10부터 모든 중첩 읽기는 부모 엘리먼트가 경계를 정하고, CMS 뒤의 0 패딩은 CMS가 선언한 길이에서 잘리며, object identifier는 결합된 첫 서브식별자를 base-128로 인코딩합니다. 경계 규칙과 OID 수정은 둘 다 오류를 일으키지 않고 틀린 답을 내놓던 코드를 갈아 치운 것이고, 패딩 규칙은 더 엄격해진 리더가 실제 서명을 거부하지 않게 지켜 줍니다

읽기 쪽은 보이는 것보다 중요합니다. 장기 검증 도구는 해지 데이터를 가져 오기 전에 기존 서명에서 서명자 인증서와 그 발급자들을 꺼내야 하고, 감사 보고서는 누가 서명했는지 말해야 하며, Linux의 Lazarus 빌드는 기댈 Windows 메시지 함수가 없습니다. 그런 위치의 파서는 나쁜 입력에서 크래시로 죽는 일이 드뭅니다. 아픈 실패 모드는 이웃의 바이트까지 세어 버린 인증서 개수, 잘못된 필드에 대해 맞춰 본 서명자 매치, 조용히 다른 OID로 변해 버린 OID입니다. 그 위에 세워진 서명 파이프라인은 자신만만한 엉터리를 보고합니다

서명된 PDF에서 서명자 인증서 읽기

다섯 개의 TPDFlib 메서드가 읽기 쪽을 맡고, 모두 InputFile, Password, FieldName을 받습니다. 각 호출은 파일을 읽기 전용으로 열고 답한 뒤 다시 닫습니다. GetSignatureEmbeddedCertificateCount와 GetSignatureEmbeddedCertificateDER는 인코딩 순서대로 놓인 인증서 집합을 나열하고, GetSignatureSignerCertificateDER는 주어진 SignerInfo를 만든 인증서를 반환하며, GetSignatureCertificateChainLength / GetSignatureCertificateChainDER는 그 서명자에서 서명 자체가 실어 온 가장 먼 발급자까지 걸어 갑니다. 인덱스는 0부터 시작합니다. 결과는 AnsiString에 보관하세요. 라이브러리가 그렇게 반환하는 데에는 이유가 있습니다. string이나 TStrings를 거친 DER 덩어리는 문자 집합 변환을 통과하면서 훼손되어 돌아옵니다

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;

그 출력에서 두 가지는 주의가 필요합니다. 개수 0은 진단이 아닙니다. 필드가 없거나, 암호가 틀렸거나, DER이 아닌 덩어리거나, 선택적인 certificates 집합을 아예 생략한 SignedData까지 전부 0이나 빈 문자열로 돌아오므로 숫자 옆에 필드 이름을 기록해 두세요. 그리고 자기 발급 인증서 전에 끝나는 체인도 오류가 아닙니다. 체인 빌더는 서명에 실린 인증서만 쓰므로, 남은 발급자들은 GetCertificateIssuerURLs가 알려 주는 주소로 가져와야 합니다

/Contents 중 실제 CMS인 부분은 얼마일까?

바깥 SEQUENCE가 선언한 접두부만이 CMS에 속하고, PLTrimCMSPadding은 그 뒤를 전부 잘라 냅니다. 서명자는 CMS가 존재하기 전에 /Contents 헥스 문자열을 예약합니다. ISO 32000-1 §12.8.1이 기술하는 /ByteRange를 먼저 확정해야 하기 때문입니다. 그래서 슬롯은 넉넉하게 잡히고 안 쓴 꼬리는 0으로 남습니다. PLTrimCMSPadding은 첫 TLV를 읽고 태그 $30을 요구하며 그 엘리먼트 끝까지의 바이트를 반환합니다. 잘 만들어진 SEQUENCE로 시작하지 않는 것은 빈 결과로 돌아옵니다. 꼬리 바이트가 합법인 곳은 이 최상위가 유일하고, 이 구분은 다음 절에서 중요해집니다. "엘리먼트가 버퍼 전체를 소비해야 한다"는 엄격한 규칙은 실제 세상의 모든 서명을 거부할 테고, 모든 깊이에 느슨한 규칙을 적용하면 중첩 필드가 자기 소유가 아닌 바이트를 읽어 버립니다

PDFlibPas PLTrimCMSPadding이 예약된 /Contents 헥스 문자열의 첫 TLV를 읽고 태그 $30을 요구해 바깥 SEQUENCE가 선언한 길이에서 0 패딩을 자르며, 버퍼가 잘 만들어진 SEQUENCE로 시작하지 않으면 빈 결과를 반환하는 모습
꼬리 바이트가 합법인 곳은 /ByteRange를 위해 예약 슬롯이 고정된 채 있어야 하는 최상위뿐입니다. 더 깊은 읽기에는 부모 경계 규칙이 대신 적용됩니다

DER 리더에 부모 끝 오프셋이 왜 필요할까?

중첩 엘리먼트는 부모 안에서 끝날 때만 유효한데, 버퍼 끝과 비교하는 것으로는 그걸 증명하지 못합니다. PDFlibASN1의 저수준 DERReadTLV는 각 엘리먼트를 문자열 전체에 대해 경계 짓는데, 최외곽 오브젝트에는 옳은 검사이지만 그 아래 모든 것에는 틀린 검사입니다. issuerAndSerialNumber가 40바이트를 선언하는데 그 안의 issuer Name은 60을 주장하는 SignerInfo를 상상해 보세요. 모든 바이트는 여전히 버퍼 안에 있으므로 버퍼 경계 리더는 Name을 받아들이고, 뒤따르는 다이제스트 알고리즘에서 일련번호를 읽어 낸 다음, 그 쌍을 내장된 인증서들과 비교합니다. v3.539.10 전의 CMS 워커는 정확히 그렇게 읽었습니다. 수정은 부모의 끝 위치를 모든 읽기에 실어 나르는 작은 래퍼입니다

PDFlibPas가 모든 중첩 DER 읽기를 부모 엘리먼트로 경계 짓는 모습: 40바이트 issuerAndSerialNumber 안의 60바이트 issuer Name은 옛 버퍼 경계 DERReadTLV가 받아들이고 digestAlgorithm에서 일련번호를 읽어 버리지만, ReadTLVWithin은 ParentEnd를 넘어 끝나는 엘리먼트는 거부합니다
버퍼 안은 메모리 안전, 부모 안은 정확성입니다. PDFlibPas는 부모 끝 오프셋을 모든 CMS 단계로 통과시켜 적대적인 길이가 이웃의 바이트를 빌릴 수 없게 합니다
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // 부모 안에 남은 것이 없으면 읽기를 시작하지 않음
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset은 이제 엘리먼트 바로 다음에 놓임, 부모를 넘으면 안 됨
  Result := Offset <= ParentEnd;
end;

// 각 단계는 자기 끝을 기록해 아래로 넘깁니다:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

PDFlibCMSRead 유닛은 이제 그 끝들을 ContentInfo, [0] EXPLICIT 래퍼, signerInfos까지의 SignedData 필드, issuerAndSerialNumber와 [0] subjectKeyIdentifier 두 형태 모두의 SignerIdentifier(RFC 5652 §5.3), 그리고 서명자를 맞춰 볼 때 각 내장 인증서에서 읽는 tbsCertificate 필드로 통과시킵니다. certificates 집합과 signerInfos 집합 안에서는 집합 끝을 넘어서는 엘리먼트가 루프를 멈춥니다. PLExtractCMSCertificates는 그때까지 받아들인 인증서를 반환하고, 뒤따르는 crls나 signerInfos 바이트를 마지막 인증서에 붙이는 일은 결코 없습니다. issuer-and-serial 매치는 두 반쪽을 모두 요구합니다. 일련번호는 하나의 발급자 안에서만 유일하니까요

2.999.3이 1.15.3으로 나온 이유는?

OID의 첫 두 아크는 한 바이트가 아니라 하나의 서브식별자로 결합되고, 그 서브식별자는 다른 아크와 마찬가지로 base-128로 인코딩됩니다. X.690 §8.19.4는 이를 40 * arc1 + arc2로 정의하는데, 예전 DER_OID는 그 값을 Byte(...)로 썼습니다. 2.47의 값인 127까지에서만 맞는 코드죠. 2.999에서 합은 1079이고, byte 캐스트는 55를 남기며, 55는 1.15로 디코딩됩니다. 식별자가 조용히 트리의 다른 가지를 가리키는 셈입니다. 128에서 255 사이의 값은 다르게 실패합니다. continuation 비트를 세팅한 한 바이트를 내보내 다음 아크를 삼켜 버립니다. 대부분의 PKI 식별자(1.2.840..., 2.5.29..., 0.4.0...)는 이 경계에 닿지 않으니 버그가 살아남았고, 2.48부터 위로는 joint-iso-itu-t 아크가 닿습니다. DER_OID는 signed attributes의 인코더와 DERFindExtensionByOID의 매처, SignedData 콘텐츠 타입 검사 모두에 쓰이므로, 잘못된 인코딩은 쓰기와 조회를 함께 망가뜨렸습니다

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는 첫 두 OID 아크를 40 * arc1 + arc2로 결합해 그 합을 UInt64에 base-128로 인코딩하므로 2.999.3은 06 03 88 37 03이 되지만, 옛 Byte 캐스트는 55를 남겨 식별자를 조용히 1.15.3으로 디코딩했습니다
대부분의 PKI 아크는 경계에 닿지 않으니 버그가 살아남았습니다. 2.48부터의 joint-iso-itu-t 아크는 두 바이트가 필요하고, 테스트는 2.47과 2.48을 양쪽에 두고 있습니다

결합된 값을 일부러 UInt64에 담았습니다. DER_OID는 아크를 Int64로 파싱하므로 합법적인 둘째 아크는 Int64.MaxValue까지 커질 수 있고, arc1 = 2의 80을 더하면 부호 있는 64비트 정수가 오버플로합니다. UInt64는 Int64.MaxValue + 80을 랩어라운드 없이 실으며, 10바이트 스크래치 버퍼는 64비트 값에 필요한 10개의 7비트 그룹을 담습니다. 간수할 가치가 있는 테스트 벡터는 경계 양쪽의 것들입니다. 2.47은 한 바이트로 남아야 하고, 2.48은 두 바이트가 되어야 합니다

읽기 쪽 CMS 워커가 보장하는 것은?

PDFlibCMSRead가 보장하는 것은 구조뿐입니다. RFC 5652가 지정한 위치에 놓인 바이트를 반환할 뿐, 서명이나 다이제스트, 유효 기간은 아무것도 검증하지 않습니다. 워커는 DER만 받아들이므로 DERReadTLV는 indefinite length와 다중 바이트 태그 번호를 거부하고, 규격을 따르지 않는 서명자의 BER 인코딩 CMS는 부분적인 추측 대신 인증서 0개를 보고합니다. attribute certificate와 나머지 CertificateChoices 대안들은 하류에서 쓸 곳이 없으므로 건너뜁니다. 암호학적 검증은 그것을 소유한 코드에 맡겨 둡니다. Delphi에서의 PAdES 서명과 ByteRange 검증에서 기술한 바이트 커버리지 검사로 시작해 PDF 서명 후 무엇이 바뀌었는지 분류하기로 이어집니다

더 넓은 교훈은 모든 바이너리 포맷에 통합니다. "버퍼 안"은 메모리 안전 속성이고 "부모 안"은 정확성 속성이며, 파서는 둘 다 필요로 합니다. 적대적인 길이에 대한 같은 사고는 악성 파일에 맞서 Pascal PDF 파서 강화하기에도 흐릅니다. 여기서 다룬 인증서 추출, 체인 빌딩, 장기 검증 API는 losLab PDF Library for Delphi에 실려 나갑니다. Delphi, C++Builder, Lazarus용입니다