기술 문서

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

PDFium Component은 중첩된 CMS 구조의 시작 위치를 내용 길이에서 찾으며 길이 옥텟 위를 거꾸로 걸어가서 찾지 않습니다. 내용 바로 앞 바이트는 마지막 길이 옥텟이고, 그 앞에 몇 개가 더 있는지는 말해 주지 않기 때문입니다. 대신 FPdfCms.pas의 CmsHeaderStart가 ContentLen에서 헤더 길이를 도출하며, DER에서는 이것이 정확합니다. 인증서 집합이 127바이트보다 긴 모든 CMS를 AddSignatureTimestampToCms가 망가뜨리지 않는 이유가 바로 여기 있습니다

배경은 PAdES B-T 업그레이드입니다. ETSI EN 319 122-1 5.3절이 OID 1.2.840.113549.1.9.16.2.14로 정의하는 서명 시각 타임스탬프 속성은 RFC 5652 5.3절이 기술하는 SignerInfo의 unsignedAttrs에 들어가야 하는데, 정의상 서명 값이 존재한 뒤에만 추가할 수 있습니다. 타임스탬프 토큰이 그 값 위에서 계산되기 때문입니다. 그래서 토큰이 도착할 때 CMS는 이미 만들어졌고 이미 서명되어 있습니다. 속성 하나를 더하면 SignerInfo의 길이가 바뀌고, 그러면 signerInfos SET의 길이, 다시 SignedData, 다시 [0] EXPLICIT 래퍼, 마지막으로 바깥 ContentInfo의 길이가 바뀝니다. 그 경로에 있는 모든 감싸는 헤더를 다시 내보내야 하고, 경로에 없는 모든 것은 바이트 단위로 넘겨야 합니다. B-LT와 B-LTA 해설은 토큰이 무엇을 주는지 다룹니다. 이 글은 재구성이 계속 틀리게 만들던, 인증서 집합 앞 네 바이트에 관한 글입니다

타임스탬프를 추가하는 데 왜 형제 요소의 태그 오프셋이 필요할까

재구성이 signerInfos SET의 형제 네 개를 그대로 재사용하는데, 리더는 그것들의 내용이 어디 있는지는 알려 주지만 태그가 어디 있는지는 알려 주지 않기 때문입니다. TDerReader.ReadTlv는 태그 바이트, 내용 오프셋, 내용 길이, 다음 TLV의 오프셋을 돌려줍니다. 구조 안으로 내려가는 데는 그것이 맞는 인터페이스지만, 요소 전체를 복사하려면 그 태그가 자리한 옥텟이 필요하고 호출자가 쥔 것은 ContentOffs뿐입니다. 그 틈을 메우려고 CmsSliceTlv가 있습니다. 내용 오프셋과 길이를 주면 태그와 길이 옥텟, 내용을 하나의 버퍼로 돌려주며, AddSignatureTimestampToCms가 contentType OID, version INTEGER, digestAlgorithms SET, encapContentInfo SEQUENCE, 그리고 있을 경우 certificates [0] 집합에 대해 그것을 호출합니다

// AddSignatureTimestampToCms 내부: 내려가면서 형제들을 그대로 슬라이스
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// 선택적 certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // 태그 + 길이 옥텟 + 내용
  R.Position:= CN;
end;

다섯 슬라이스 중 넷은 아주 작습니다. 11바이트 OID, 3바이트 INTEGER, 17바이트 다이제스트 알고리즘 집합, 13바이트 분리형 encapContentInfo입니다. 인증서 집합은 서명자 인증서와 그 체인을 담는 유일한 슬라이스이고, 실제 X.509 인증서는 최소한 수백 바이트입니다. 따라서 인증서 집합은 길이 옥텟이 긴 형식이 되는 유일한 슬라이스이며, 예전 헬퍼가 위치를 찾지 못한 슬라이스입니다

CMS에 PAdES B-T 타임스탬프를 추가할 때 재구성되는 것: CmsSliceTlv가 contentType, version, digestAlgorithms, encapContentInfo와 인증서 집합을 바이트 단위로 복사하고, 인증서 집합이 짧은 길이 형식을 벗어날 만큼 긴 유일한 슬라이스이며, SignerInfo부터 ContentInfo까지 모든 감싸는 헤더가 다시 내보내집니다
서명 대상 부분은 서명 OCTET STRING까지의 SignerInfo 앞부분이 그대로 복사되므로 구조적으로 손대지 않으며, 그래서 signedAttrs를 다시 다이제스트하는 검증기가 타임스탬프가 들어간 전후에 같은 바이트를 봅니다

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

길이 옥텟의 개수가 그중 첫 번째에 저장되어 있고, 내용에서 거꾸로 읽으면 마지막 것을 먼저 만나기 때문입니다. X.690 8.1.3.4절은 짧은 형식을 정의합니다. 옥텟 하나이고 8번 비트가 0이며 7번부터 1번 비트가 0에서 127까지의 길이를 담습니다. 8.1.3.5절은 긴 형식을 정의합니다. 8번 비트가 1인 첫 옥텟의 7번부터 1번 비트가 뒤따르는 옥텟 개수를 주고, 그 옥텟들이 길이를 부호 없는 빅엔디언 정수로 담습니다. 규칙 어디에도 뒤따르는 옥텟임을 표시하는 것은 없습니다. 그 8번 비트도 다른 비트와 같은 크기 비트이므로, Buf[ContentOffs- 1]의 최상위 비트를 검사하는 거꾸로 걷기는 데이터 비트를 검사하고 그 하위 7비트를 개수로 읽는 셈입니다

// 내용 오프셋만 주어지던 예전 헬퍼
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // 마지막 길이 옥텟에 놓임
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 첫 번째일 때만 의미 있음
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// 1500바이트 인증서 집합의 헤더:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 set, $DC and $7F= 92
//   Result= ContentOffs- 94     (태그는 ContentOffs- 4에 있음)

인증서 1500바이트를 담은 인증서 집합의 헤더 A0 82 05 DC를 보겠습니다. 걷기는 DC에 놓이고, 최상위 비트가 설정된 것을 보고 하위 7비트에서 92를 뽑아 태그가 내용 94바이트 앞에 있다고 보고합니다. 실제로는 4바이트 앞입니다. BuildSignedData가 만든 SignedData에서는 인증서 집합 내용이 CMS 안쪽 수십 바이트 지점에 있으므로, 계산된 오프셋은 이른 정도가 아니라 음수였고, 예전 코드는 ContentOffs- 1이 0 아래로 내려가는 것만 막았지 최종 결과를 막지 않았습니다. 그러면 CmsSliceTlv가 요소보다 90여 바이트 긴 슬라이스를 버퍼 시작점보다도 앞에서 떠 갔고, 재구성된 SignedData는 인증서 집합이 있어야 할 자리에 그 슬라이스를 담았습니다. 마지막 옥텟이 우연히 $80 아래로 떨어지는 3옥텟 길이, 이를테면 A0 82 05 10은 반대 방향으로 실패했습니다. 걷기가 그것을 짧은 형식 옥텟으로 보고 슬라이스를 05에서 시작해 두 바이트 늦고 길이 옥텟 안쪽에서 시작했으며, 태그는 아예 없었습니다. 어느 쪽이든 결과는 틀렸고 방향만 달랐습니다

DER 길이 옥텟을 거꾸로 걸어갈 수 없는 이유: A0 82 05 DC를 끝에서 읽으면 마지막 옥텟 DC에 놓이고, 설정된 최상위 비트가 92라는 엉뚱한 개수를 만들어 태그를 94옥텟 이르게 배치하는 반면, A0 82 05 10은 반대 방향으로 실패해 슬라이스를 길이 옥텟 안에서 두 옥텟 늦게 시작합니다
예전 CmsHeaderStart는 최종 결과가 아니라 중간 뺄셈을 막았으므로 슬라이스가 버퍼보다 앞에서 시작할 수도 있었고, 재구성된 SignedData는 그 슬라이스를 인증서 집합이 있어야 할 자리에 담았습니다

DER이 보장하는 무엇이 정방향 도출을 정확하게 할까

DER은 길이 인코딩이 길이의 순수 함수임을 보장합니다. X.690 10.1절은 DER을 확정 형식으로 제한하고 최소 옥텟 수를 요구하며, 그로써 BER이 허용하는 두 가지 자유 — 무한 형식과 긴 형식 길이를 앞쪽 0 옥텟으로 채우는 것 — 를 없앱니다. 그 규칙 아래에서 128 미만의 내용 길이는 길이 옥텟이 정확히 하나이고, 그 밖의 길이는 첫 옥텟 하나에 더해 길이가 유효 바이트로 필요로 하는 만큼의 뒤따르는 옥텟을 정확히 갖습니다. CmsHeaderStart의 호출자는 ReadTlv가 방금 돌려준 ContentLen을 이미 쥐고 있으므로, 헤더 길이는 버퍼를 한 바이트도 보지 않고 계산됩니다

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 형식을 거부한 상태입니다
32바이트와 64바이트 인증서로 만든 픽스처는 짧은 형식 안에 머물렀고 거기서는 거꾸로 걷기가 틀린 이유로 맞는 답을 냈습니다. 그래서 경계 테스트 스위트가 이제 127, 128, 255, 256바이트를 넘나듭니다
// 배포된 헬퍼: 내용 길이에서 헤더를 도출
// X.690 10.1에서 길이 옥텟은 ContentLen의 함수
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // 짧은 형식, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // 첫 옥텟, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 유효 바이트마다 하나
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

이것을 그럴듯한 것이 아니라 안전하게 만드는 세부가 두 가지입니다. 첫째, 입력이 DER이라는 가정은 상류에서 강제됩니다. ReadTlv가 기반으로 하는 TDerReader.TryReadTlvAt은 무한 형식을 거부하고, 첫 뒤따르는 옥텟이 0인 긴 형식 길이를 거부하며, $80 미만인 단일 뒤따르는 옥텟을 거부합니다. CmsSliceTlv에 도달한 TLV는 이미 그 검사를 통과했으므로, BER식 비최소 길이가 도출에 닿아 거짓말을 하게 할 수 없습니다. 둘째, 음수 결과에 대한 폴백이 이제 중간값이 아니라 진짜 답을 막습니다. 리더가 태그 오프셋을 처음부터 알고 있었다는 점은 짚어 둘 만합니다. TDerTlv는 Offset과 HeaderLength를 모두 지니고 있고, 네 개 출력 매개변수인 ReadTlv 인터페이스만 그것들을 버립니다. 그것들을 돌려주는 것이 장기적으로 더 깔끔한 인터페이스일 것입니다. 배포된 수정은 그 인터페이스를 그대로 두고 헬퍼를 자기 기준에서 올바르게 만듭니다

버그가 있는데도 타임스탬프 테스트가 통과한 이유

모든 픽스처 인증서가 짧은 형식을 쓸 만큼 짧았고, 거꾸로 걷기는 정확히 그 경우에 맞기 때문입니다. Tests.PadesTimestamp.pas는 한 테스트에서 SetLength(SignerCertDer, 32)로, 다른 테스트에서 64로 서명자 인증서를 만들고 바이트 램프로 채웁니다. 32바이트 인증서 집합은 A0 20, 64바이트는 A0 40으로 인코딩되어 길이 옥텟이 각각 하나입니다. 내용에서 거꾸로 걸으면 그 한 옥텟에 놓이고, 그것이 유일한 길이 옥텟이라 최상위 비트가 0이므로 헬퍼는 틀린 이유로 맞는 답을 냅니다. 1414개 케이스 스위트는 초록이었고, 타임스탬프가 들어간 CMS는 파싱되었고, 1단계 검증기는 B-T를 보고했으며, 그 모든 검사가 실제 문서에는 결코 담기지 않는 인증서 집합을 상대로 실행되었습니다

일반 규칙이 유용한 부분입니다. 코드 경로가 길이 인코딩 방식에 의존한다면 픽스처는 인코딩 경계를 넘어야 하고, DER에서 그것은 127바이트보다 긴 내용, 즉 긴 형식을 강제하는 길이를 뜻하며, 이상적으로는 255바이트보다도 길어 두 번째 뒤따르는 옥텟을 강제해야 합니다. 같은 규율은 그 리뷰에서 자기 검증이 DER 편차를 보지 못한 다른 사례에도 적용됩니다. signedAttrs의 정렬되지 않은 SET OF도 구조적으로 같은 이유로 동일 출처 왕복에서 보이지 않았습니다. 테스트가 틀린 코드와 맞는 코드가 일치하는 입력만 행사했기 때문입니다. 아래 스케치는 슬라이스 헬퍼를 직접 호출하므로 테스트 빌드를 위해 FPdfCms.pas에서 그것을 export해야 합니다. 같은 경계는 BuildSignedData에 각 크기의 체인 인증서를 넘기고 타임스탬프가 들어간 결과를 다시 파싱하는 방식으로 공개 인터페이스를 통해서도 도달할 수 있습니다

// 경계 고정: 긴 형식 헤더를 지나는 슬라이스는 태그에서 시작해야 함
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // 내용은 헤더 바로 뒤에서 시작하며, 슬라이스는 TLV 전체여야 함
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

재구성이 여전히 선을 긋는 지점

AddSignatureTimestampToCms는 BuildSignedData가 내보내는 CMS를 위해 작성되었고, 그 한계는 거기서 따라옵니다. 이 순회는 SignerInfo 하나를 기대하고 그것만 다시 내보내므로, 외부의 다중 서명자 CMS는 서명자 하나로 돌아옵니다. 선택적 certificates [0] 집합은 인식하지만 crls [1] 집합은 인식하지 못하며, 그것을 지닌 CMS는 조용히 잘못 슬라이스되는 대신 signerInfos SET expected 예외로 크게 실패합니다. 새 unsignedAttrs는 속성 하나를 담으므로 X.690 11.6절의 SET OF 정렬 규칙이 자명하게 충족되어 정렬이 필요 없습니다. 그리고 서명 대상 부분은 구조적으로 손대지 않습니다. 서명 OCTET STRING까지의 SignerInfo 앞부분이 그대로 복사되며, 그래서 signedAttrs를 다시 다이제스트하는 검증기가 타임스탬프가 추가되기 전후에 같은 바이트를 봅니다. 그래도 문서가 거부된다면 원인은 대개 다른 곳에 있으며 따로 점검할 가치가 있습니다

DER 리더와 작성기, CMS 빌더, 이 타임스탬프 주입은 모두 PDFium Delphi 컴포넌트와 함께 Pascal 소스로 배포됩니다. 이런 모양의 버그가 그 근거입니다. 재구성된 SignedData가 90바이트 길게 나올 때, 블랙박스의 스택 트레이스가 아니라 슬라이스를 자른 헬퍼와 그것이 잘못 읽은 X.690 조항을 읽고 싶어지기 때문입니다