기술 문서

PDFium에서 DER 길이 옥텟을 거꾸로 걸어가면 안 되는 이유

네 바이트 A0 82 05 DC는 1500바이트의 콘텐츠를 실은 문맥 특정 [0] 요소를 엽니다. 왼쪽에서 오른쪽으로 읽으면 모호함이 없습니다. 태그 A0, 두 옥텟이 더 따라온다고 알리는 첫 길이 옥텟 82, 그리고 05 DC. 첫 콘텐츠 바이트에서 오른쪽에서 왼쪽으로 읽기 시작하면 처음 만나는 옥텟은 DC입니다. 최상위 비트가 켜져 있고 하위 일곱 비트는 92를 말하므로, 이것을 믿는 도우미는 태그가 콘텐츠 94바이트 앞에 있다고 보고합니다. 실제 거리는 4입니다

아무도 일부러 뒤로 걷지 않습니다. 대부분의 DER 리더가 각 요소의 콘텐츠 오프셋과 길이를 돌려주고, 그것은 요소 안으로 내려가는 데 꼭 필요한 것이며, 반면 형제 요소를 그대로 복사하는 데는 태그의 오프셋이 필요하므로 뒤로 걷게 됩니다. 콘텐츠 오프셋에서 빼는 것은 두 줄짜리 작업처럼 보이고, 모든 길이가 한 옥텟에 들어가는 한 올바르게 유지됩니다. 진짜 인증서 집합의 길이는 하나에 들어가지 않습니다

서명 타임스탬프가 서명된 CMS에 하는 일

PAdES B-T 타임스탬프를 추가하면 CMS의 감싸는 모든 헤더를 다시 인코딩하는 한편, 다섯 형제 요소는 바이트 단위로 그대로 옮겨야 합니다. ETSI EN 319 122-1 5.3절이 OID 1.2.840.113549.1.9.16.2.14 아래 정의하는 signature-time-stamp 속성은 RFC 5652 5.3절의 SignerInfo가 가진 unsignedAttrs에 살며, 그 안의 RFC 3161 토큰은 서명 값 위에서 계산됩니다. 그러므로 토큰이 TSA에서 도착할 때 CMS는 이미 만들어져 있고 이미 서명되어 있습니다. 속성 하나가 더해지면 SignerInfo가 길어지고, 이어서 signerInfos SET이, 그다음 SignedData가, 그다음 [0] EXPLICIT 래퍼가, 마지막으로 바깥 ContentInfo가 길어집니다

PDFium Component에서 그 순서는 FPdfCms.pas와 FPdfTsa.pas의 세 개의 익스포트된 호출입니다. TPadesSignOptions.Level이 plB_T 이상이고 TsaUrl이 설정되어 있으면 SignPadesBytes가 여러분을 위해 이들을 실행하고, 빈 TsaUrl으로 B-T 레벨이 요청되면 평범한 B-B 서명을 쓰는 대신 EPadesCrypto를 던집니다. BuildSignedData로 CMS를 직접 만든다면 체인은 다음과 같이 보입니다

// CmsDer는 BuildSignedData가 돌려준 ContentInfo를 담습니다.
// SignPadesBytes는 Options.Level >= plB_T일 때 같은 세 단계를 수행합니다
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // TSA의 messageImprint는 서명 값 옥텟 위에서 계산됩니다
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // SignerInfo부터 ContentInfo까지 다시 인코딩하고 형제는 그대로 복사합니다
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms는 다섯 요소를 손대지 않고 옮깁니다. contentType OID, SignedData 버전 INTEGER, digestAlgorithms SET, encapContentInfo SEQUENCE, 그리고 있다면 certificates [0] 집합입니다. BuildSignedData가 SHA-256으로 내놓는 CMS에서 이 중 넷은 작습니다. OID 11바이트, 버전 3바이트, 다이제스트 알고리즘 집합 17바이트, 분리된 encapContentInfo 13바이트. 인증서 집합은 서명자 인증서와 여러분이 넘긴 모든 체인 인증서를 담습니다. 진짜 X.509 인증서는 수백 바이트에 이릅니다. 길이 옥텟이 언제나 장형을 쓰는 유일한 조각이 바로 이것입니다

PAdES B-T 타임스탬프 추가가 CMS에서 다시 만드는 것: AddSignatureTimestampToCms는 contentType, 버전, digestAlgorithms, encapContentInfo, certificates 집합을 바이트 단위로 복사하고, 인증서 집합은 단형 길이를 벗어날 만큼 긴 유일한 조각이며, SignerInfo부터 ContentInfo까지 감싸는 모든 헤더가 다시 출력됩니다
서명된 부분은 구조상 손대지 않습니다. SignerInfo 접두부부터 signature OCTET STRING까지 통째로 복사되므로, signedAttrs를 다시 다이제스트하는 검증기는 타임스탬프가 들어오기 전과 후에 같은 바이트를 봅니다

DER 길이 옥텟을 거꾸로 걸어갈 수 없는 이유

길이 옥텟의 개수는 그 첫 옥텟에 저장되어 있고, 뒤에서부터 걷는 보행은 마지막 옥텟을 먼저 만납니다. X.690 8.1.3.4절이 단형을 정의합니다. 하나의 옥텟, 비트 8이 꺼져 있고 비트 7에서 1이 0에서 127의 길이를 담습니다. 8.1.3.5절이 장형을 정의합니다. 첫 옥텟은 비트 8이 켜져 있고, 비트 7에서 1은 길이를 부호 없는 빅엔디안 정수로 담는 뒤따르는 옥텟의 개수를 셉니다. 뒤따르는 옥텟임을 표시하는 것은 아무것도 없습니다. 그 옥텟의 비트 8은 나머지 일곱 비트처럼 크기 비트입니다. 그래서 Buf[ContentOffs- 1]의 최상위 비트를 검사하는 도우미는 데이터를 검사한 뒤 데이터를 개수로 읽고 있는 것입니다

// 콘텐츠 오프셋만 주어지는 유혹적인 도우미. 실어 보내지 마세요
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // 마지막 길이 옥텟에 떨어집니다
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 첫 옥텟일 때만 의미가 있습니다
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC(1500바이트): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, 태그는 ContentOffs- 4에 있습니다
// A0 82 05 10(1296바이트): $10은 비트 8이 꺼져 있어 단형으로 읽힙니다
//   Result= ContentOffs- 2, 길이 옥텟 안의 05를 가리킵니다

두 실패 방향은 평범한 크기에서 모두 나타납니다. 1500바이트 인증서 집합은 헤더를 DC로 끝내고, 도우미는 조각을 90바이트 일찍 시작합니다. 1296바이트 집합은 10으로 끝나고, 도우미는 이것을 단형 길이로 착각해 조각을 두 바이트 늦게 시작하며 태그조차 가리키지 않습니다. 주어진 체인이 첫 사례에 걸리는지 두 번째에 걸리는지는 전체 길이의 낮은 바이트가 무엇인가로만 갈립니다

이른 사례는 버퍼 맨 앞을 넘어설 수도 있습니다. 모든 감싸는 길이가 두 옥텟을 쓰는, BuildSignedData가 만드는 배치의 CMS에서 인증서 집합의 콘텐츠는 60바이트 지점에서 시작합니다. ContentInfo 헤더 4, OID 11, [0]과 SignedData 헤더 각 4, 작은 형제들 3과 17과 13, 인증서 집합 헤더 4. 60에서 94를 빼면 도우미는 -34를 돌려줍니다. 유일한 방어는 ContentOffs- 1에 있으므로 음수 결과가 복사로 곧장 들어갑니다

DER 길이 옥텟을 거꾸로 걸을 수 없는 이유: A0 82 05 DC를 끝에서 읽으면 마지막 옥텟 DC에 떨어지고, 켜진 최상위 비트는 엉터리 개수 92를 내어 태그를 94옥텟 앞에 놓으며, A0 82 05 10은 반대 방향으로 실패해 조각을 길이 옥텟 안에서 두 옥텟 늦게 시작합니다
최종 결과 대신 중간 뺄셈을 방어하는 뒤로 걷기는 조각을 버퍼보다 앞에서 시작하게 할 수도 있고, 다시 만든 SignedData는 인증서 집합이 있어야 할 자리에 그 조각을 남겨 둡니다

X.690 10.1절이 못 박는 것

DER는 길이 인코딩을 길이의 순수 함수로 만듭니다. X.690 10.1절은 DER를 정형으로 한정하고 최소 옥텟 수를 요구하여, BER가 허용하는 두 자유, 즉 무정형과 앞에 0 옥텟을 붙여 늘인 장형 길이를 제거합니다. 그 규칙 아래에서 128 미만의 콘텐츠 길이는 언제나 정확히 하나의 길이 옥텟을 씁니다. 그 외의 길이는 첫 옥텟 하나에 값의 유효 바이트 수만큼의 뒤따르는 옥텟을 더합니다. 128은 81 80이 됩니다. 256에서 헤더는 82 01 00으로 자라고, 65536은 83 01 00 00이 필요합니다. 콘텐츠 길이를 이미 쥔 코드는 버퍼의 바이트를 하나도 읽지 않고 헤더 크기를 계산할 수 있습니다

DER가 보장하는 순방향 도출: 콘텐츠 길이 127은 A0 7F로, 128은 A0 81 80으로, 255는 A0 81 FF로, 256은 A0 82 01 00으로, 1500은 A0 82 05 DC로 인코딩되므로 헤더 길이는 ContentLen만으로 따라 나오고 TryReadTlvAt는 최소형이 아닌 모든 BER 형태를 이미 거부했습니다
128바이트 미만의 콘텐츠는 단형 안에 머무는데, 여기서는 뒤로 걷기가 잘못된 이유로 올바른 답을 내므로 경계 테스트는 127, 128, 255, 256바이트를 넘어야 합니다

도출은 입력이 실제로 DER라는 주장만큼만 좋습니다. TDerReader.TryReadTlvAt는 슬라이싱이 일어나기 전에 그 주장을 강제합니다. $80의 첫 길이 옥텟(무정형), 첫 뒤따르는 옥텟이 0인 장형 길이, 단형이어야 했을 $80 미만의 단일 뒤따르는 옥텟, 넷을 넘는 뒤따르는 옥텟을 거부합니다. ReadTlv는 TryReadTlvAt 위에 세워져 있으므로 AddSignatureTimestampToCms가 걷는 모든 요소는 이미 그 검사를 통과했습니다. BER식 패딩 길이는 산술에 닿지 않습니다

AddSignatureTimestampToCms 안에서 각 형제의 태그 오프셋은 콘텐츠 오프셋과 콘텐츠 길이에서 이런 식으로 도출됩니다. 도출된 시작이 버퍼의 시작보다 앞서면, 즉 오프셋들이 함께일 수 없으면 프로시저는 CMS: TLV header precedes the buffer start라는 메시지로 예외를 던지고 CMS를 전혀 만들지 않습니다. 그럼에도 파싱되는 불량 재구축은 오프셋이 어긋난 지점의 예외보다 추적이 훨씬 어렵습니다

도출에는 가정이 하나 더 있습니다. 식별자가 하나의 옥텟을 쓴다는 것입니다. X.690은 31 이상의 태그 번호에 여러 옥텟 식별자를 허용하고 TryReadTlvAt는 그것을 파싱합니다. CMS SignedData는 태그가 universal SEQUENCE, SET, INTEGER, OID와 문맥 특정 [0], [1]뿐이므로, 이 가정은 타임스탬프 재구축이 만지는 모든 요소에서 성립합니다

여러분 자신의 DER 코드에서 형제 요소 복사하기

TDerReader에 대고 코드를 쓴다면 도출은 아예 건너뛰세요. PeekTlv와 ReadNextTlv는 Offset이 이미 태그를 가리키고 HeaderLength가 태그 더하기 길이 옥텟의 측정된 크기인 TDerTlv 레코드를 채우며, EncodedBytes는 Offset부터 NextOffset까지의 요소를 돌려줍니다. 이 경로는 길이 산술로는 어떻게 해도 복구할 수 없는 여러 옥텟 식별자까지 다룹니다. 네 out 매개변수의 ReadTlv는 내려가는 데 편리합니다. 다만 콘텐츠 쪽만 보고하므로, 콘텐츠가 필요할 때는 그것을 요소 전체가 필요할 때는 레코드 형태를 쓰세요

// SignedData의 모든 자식을 완전한 TLV로: 태그, 길이 옥텟, 콘텐츠.
// TDerTlv.Offset는 태그를 가리키므로 헤더 산술이 전혀 필요 없습니다
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // 예: A0 82 05 DC ...
  end;
end;

다른 API가 콘텐츠 오프셋과 길이밖에 주지 않는 경우에는 순방향 도출이 짧습니다. 다음 절에 경계 검사와 함께 보이는 DerHeaderStart는 여러분 자신의 코드입니다. 10.1절의 규칙을 반영하고 일관될 수 없는 오프셋에 -1을 돌려주므로, 호출자는 그럴듯해 보이는 시작 바이트 대신 거부할 수 있는 값을 얻습니다

짧은 테스트 인증서는 왜 실수를 숨기나?

작은 자리표 인증서는 인증서 집합을 단형으로 유지하고, 뒤로 걷기는 정확히 그 사례에서만 옳습니다. 32바이트 집합은 A0 20으로, 64바이트 집합은 A0 40으로 인코딩됩니다. 콘텐츠에서 뒤로 걷으면 그 하나의 길이 옥텟 위에 떨어지는데, 첫이자 유일한 옥텟이므로 최상위 비트가 꺼져 있습니다. 올바른 답, 잘못된 이유. 그만한 크기의 인증서에 세워진 타임스탬프 테스트는 결과를 다시 파싱하고 속성을 찾아 B-T를 보고할 수 있지만, 현실적인 크기의 인증서 집합을 슬라이스에 통과시킨 적은 한 번도 없습니다

길이의 인코딩 방식에 의존하는 코드 경로에는 인코딩 경계를 넘는 테스트 입력이 필요합니다. DER에서 그것은 장형을 강제하는 128바이트 이상의 콘텐츠와 두 번째 뒤따르는 옥텟을 강제하는 256바이트 이상의 콘텐츠를 뜻합니다. 아래 검사는 DerHeaderStart를 파싱하면서 리더가 측정한 헤더와 비교하는데, 두 경계와 서두의 1500바이트 사례에서 수행합니다. signedAttrs의 정렬되지 않은 SET OF가 같은 구조적 이유로 동일 기원 왕복을 슬쩍 지나가는 것도 마찬가지입니다. 테스트가 먹이는 입력이 틀린 코드와 옳은 코드가 같은 답을 내는 입력뿐이기 때문입니다

// 콘텐츠 오프셋과 길이만 보고하는 API를 위한 여러분 자신의 도우미.
// 한 옥텟 식별자를 가정하며 CMS SignedData의 모든 태그에서 참입니다
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // 단형, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // 장형, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 유효 바이트당 하나씩
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // 일치하지 않는 오프셋: 호출자가 예외를 내도록
end;

// 경계 검사, 여러분 자신의 테스트 코드로 쓴 것: 도출된 태그 오프셋은
// 헤더를 파싱하며 TDerReader가 측정한 오프셋과 같아야 합니다
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

같은 경계는 익스포트된 함수를 통해 끝까지 도달할 수도 있습니다. 합쳐서 인증서 집합을 128과 256바이트의 조금 아래와 조금 위에 놓이게 하는 체인 인증서를 BuildSignedData에 넘기고, 각 결과에 AddSignatureTimestampToCms를 실행한 뒤, 출력을 SignedDataChildren으로 걸으며 인증서 집합 요소를 타임스탬프를 더하기 전 CMS의 그것과 바이트 단위로 비교하세요

AddSignatureTimestampToCms가 받아들이는 것

AddSignatureTimestampToCms는 BuildSignedData가 내놓는 CMS를 위해 쓰였습니다. 하나의 SignerInfo, 선택적 certificates [0] 집합, crls [1] 없음, 아직 unsignedAttrs 없음입니다. 둘 이상의 SignerInfo를 담은 signerInfos SET을 먹이면 나머지 서명자를 버리고 첫째를 취하는 대신 예외를 던집니다. crls [1] 집합을 실은 CMS는 CMS: signerInfos SET expected 예외에서 멈춥니다. 인증서 뒤의 요소가 보행이 찾는 SET이 아니기 때문입니다. 두 거부는 모두 CmsDer에 무언가가 다시 쓰이기 전에 일어납니다

새 unsignedAttrs는 정확히 하나의 속성을 담으므로 X.690 11.6절의 SET OF 순서 규칙은 정렬 없이 만족됩니다. 버전부터 signature OCTET STRING까지의 SignerInfo 서명 부분은 하나의 블록으로 복사되는데, 이는 signedAttrs를 다시 다이제스트하는 검증기가 토큰이 더해지기 전과 후에 같은 바이트를 본다는 뜻입니다. 검증기가 그래도 문서를 거부한다면 원인은 보통 다른 곳에 있고 PAdES 거부 사유 체크리스트가 그것을 다룹니다. 토큰이 시간이 지나며 사 주는 것과 B-LT와 B-LTA가 그 위에 무엇을 세우는지는 RFC 3161 타임스탬프와 DSS 해설에 있습니다

FPdfAsn1.pas의 DER 리더와 라이터, FPdfCms.pas의 CMS 빌더와 타임스탬프 단계는 모두 Delphi용 PDFium Component와 함께 오는 Pascal 소스입니다. 다시 만든 SignedData가 검증기에서 파싱을 거부할 때 각 요소를 자른 코드를 한 단계씩 걸어 그것이 인용하는 X.690 절과 맞는지 확인할 수 있습니다