기술 문서

PDFium 컴포넌트의 CMS signedAttrs DER SET OF 정렬

PDFium 컴포넌트는 X.690 절 11.6이 SET OF의 멤버를 그 자신의 DER 인코딩의 오름차순으로 방출하도록 요구하기 때문에, TDerWriter.SetOfSorted로 CMS signedAttrs 블록을 만듭니다. 대신 생성 순서로 조립하면, 자기 검증기는 받아들이고 모든 타사 PAdES 검증기가 거부하는 서명이 나옵니다

마지막 문장이 이 결함이 전체 회귀 테스트 스위트를 살아남은 이유의 전부입니다. 서명 경로는 커버됐고, 검증 경로도 커버됐고, 왕복은 모든 플랫폼에서 초록불이었으며, 그런데도 출력은 여전히 유효한 DER이 아니었습니다. 자기 라이터로 서명하고 자기 리더로 검증하는 테스트는 인코딩 규칙으로부터의 이탈을 감지할 수 없습니다. 양쪽 절반이 그 이탈을 공유하기 때문입니다

X.690 절 11.6이 실제로 요구하는 것

SET OF 값의 멤버는 정렬되어야 하고, 정렬 키는 각 멤버의 완전한 DER 인코딩이며, 부호 없는 옥텟 문자열로 비교하고, 하나가 다른 것의 프리픽스일 때는 더 짧은 인코딩이 먼저 옵니다. 이것은 제안이 아니라 표준 제약입니다. DER은 주어진 값에 대해 정확히 하나의 인코딩만 허용하므로, 정렬되지 않은 SET OF는 같은 값의 다른 순서 인코딩이 아니라 아예 DER 인코딩이 아닙니다. ASN.1 태그가 TLV에서 맨 앞에 오므로 비교는 보통 처음 두 옥텟 안에서 끝나고, CMS 속성 목록의 모든 멤버가 SEQUENCE 태그 30으로 시작하므로 동률은 길이 옥텟에서 갈립니다. 그래서 요구되는 순서는 거의 속성을 크기순으로 정렬한 것처럼 보이며, 16진수 덤프를 보고 왜 순서가 임의적으로 보이는지 궁금해할 때 알아 두면 좋습니다

X.690 절 11.6이 CMS signedAttrs SET OF를 정렬하는 방식을 보여주는 PDFium 컴포넌트 문서용 다이어그램: BuildSignedAttrs는 content-type, message-digest, signing-certificate-v2, cms-algorithm-protection을 생성 순서로 추가하지만, 준수하는 DER은 네 멤버를 완전한 인코딩 30 18, 30 2D, 30 2F, 30 37 순서로 정렬합니다
네 멤버 모두 SEQUENCE 태그 30을 지니고 길이 옥텟이 비교를 결정하므로, 생성 순서와 인코딩 순서는 첫 서명부터 어긋납니다

최소 속성 집합이 왜 이미 순서가 어긋나는가

선택 속성이 전혀 관여하지 않은 채, 자연스러운 생성 순서와 인코딩 순서가 여러분이 만드는 첫 서명부터 어긋나기 때문입니다. FPdfCms.pas의 BuildSignedAttrs는 content-type, message-digest, signing-certificate-v2, cms-algorithm-protection 순으로 추가합니다. SHA-256 다이제스트와 RSA 서명 알고리즘을 쓰면 네 속성 TLV가 다음 바이트들로 시작합니다

// BuildSignedAttrs가 생성하는 구성 순서
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// X.690 11.6이 요구하는 DER 순서
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

네 속성 중 둘이 움직입니다. 30 2D의 cms-algorithm-protection은 30 37의 signing-certificate-v2보다 앞서야 하고, 30 2F의 message-digest보다도 앞서야 합니다. 그래서 PDFium 컴포넌트가 방출할 수 있는 가장 작은 준수형 PAdES B-B 속성 집합도, 누군가 signing-time이나 commitment-type-indication, signature-policy-identifier를 추가하기 전에 이미 옛 코드 아래에서 순서가 어긋나 있었습니다. 그 버그가 발화하지 않는 설정은 존재하지 않았습니다

자체 테스트는 통과하는데 외부 검증기는 왜 모두 실패하는가

두 계열의 검증기가 서로 다른 바이트에 대해 메시지 다이제스트를 계산하기 때문입니다. RFC 5652 절 5.4는 서명 연산의 입력이 SignedAttributes 값의 DER 인코딩이고, IMPLICIT [0] 태그가 SET 태그 31로 바뀐다고 말합니다. 그리고 모든 것을 결정하는 문구는 받은 옥텟이 아니라 DER 인코딩입니다. CryptVerifyDetachedMessageSignature로 도달하는 Windows 플랫폼 검증기는 받은 옥텟을 가져다 앞 태그를 바꾸고 도착한 그대로 해시하므로, 순서는 거기에 전혀 들어가지 않고 순서가 어긋난 SET OF도 서명자가 계산한 것과 정확히 같은 다이제스트가 됩니다. 널리 배포된 유럽 검증기들이 기반으로 하는 OpenSSL과 BouncyCastle은 속성 집합을 구조로 파싱한 뒤 다이제스트 전에 다시 인코딩합니다. 재인코딩이 정렬을 적용하므로 다이제스트가 서명된 것과 더 이상 일치하지 않고, 서명은 잘못 인코딩됐다기보다 깨졌다고 보고됩니다. 그래서 비교 함수 하나에 사는 결함을 찾으러 키 자료와 인증서 체인을 뒤지게 됩니다

정렬된 SET OF 버그 뒤의 검증기 분기를 보여주는 PDFium 컴포넌트 다이어그램: 서명자는 SET 태그 31이 있는 DER 인코딩을 해시하고, Windows CryptVerifyDetachedMessageSignature 경로는 받은 옥텟을 다이제스트하므로 순서는 중요하지 않아 순서가 어긋난 서명이 통과하지만, OpenSSL과 BouncyCastle은 재인코딩해 X.690 정렬을 적용하고 서명을 깨진 것으로 보고합니다
CryptVerifyDetachedMessageSignature는 옥텟을 도착한 그대로 다이제스트하므로 순서가 어긋난 집합도 검증되지만, OpenSSL과 BouncyCastle은 다이제스트 전에 재인코딩해 서명을 잘못 인코딩됐다기보다 깨진 것으로 보고합니다

이 버그 하나를 넘어 일반화하세요. 그것이 비싼 부분이기 때문입니다. 동일 기원의 왕복은 코드의 양쪽 절반이 공유하는 상호운용성 스펙으로부터의 모든 이탈에 면역입니다. 자기 라이터와 자기 리더가 서로 동의한다는 것만 증명하며, 아무도 신경 쓰지 않는 속성입니다. 프로세스를 와이어 포맷으로 떠나는 모든 것에 대해, 의미 있는 회귀 테스트는 바이트를 스펙에 대고 고정하거나 여러분의 출력 위에서 외부 구현을 돌리는 것뿐입니다. OpenSSL CMS 검증 백엔드는 이식성 말고 두 번째 이유로 여기서 제 몫을 합니다. 그것은 여러분의 DER을 읽는 진정한 독립 리더입니다

수정, 그리고 그것이 이미 있던 곳

DER 라이터는 정렬 변형을 처음부터 갖고 있었습니다. FPdfAsn1.pas의 TDerWriter는 미리 연결된 콘텐츠 버퍼를 SET 태그로 감싸고 건네받은 순서를 그대로 보존하는 SetOf와, 멤버를 TDerByteArrays로 개별적으로 받아 감싸기 전에 인코딩으로 병합 정렬하는 SetOfSorted를 모두 노출합니다. FPdfMac.pas의 PDF MAC 코드는 이미 SetOfSorted로 인증된 속성 집합을 만들고 있었습니다. CMS 서명자만 그렇지 않았습니다. 멤버를 감싸는 호출 직전까지 배열로 유지하는 것이 수정의 구조적 절반입니다. 속성이 단일 버퍼로 연결되고 나면 순서 정보는 사라지고, 하류의 어떤 주의도 그것을 복원할 수 없기 때문입니다. SET OF에 대해 SetOf(Content: TBytes)만 제공하는 라이터 API는 이 버그를 초대하는 API입니다. 배열을 받는 오버로드가 올바른 것을 표현 가능하게 만듭니다

PDFium 컴포넌트의 TDerWriter API 쌍 다이어그램: SetOf는 미리 연결된 TBytes 버퍼를 감싸 건네받은 멤버 순서를 그대로 보존해 DER이 아닌 출력을 만들지만, SetOfSorted는 멤버를 TDerByteArrays 배열로 유지해 완전한 인코딩으로 병합 정렬하고 준수하는 SET OF를 감쌉니다
속성이 단일 버퍼로 연결되고 나면 순서 정보는 사라지므로, 배열을 받는 SetOfSorted 오버로드가 인코딩 시점에 올바른 것을 표현 가능하게 만듭니다
// 이전: 멤버는 AddAttr이 추가한 순서를 유지합니다
Result:= W.SetOf(ConcatMany(AttrList));

// 이후: X.690 11.6 순서가 인코딩 시점에 적용됩니다
Result:= W.SetOfSorted(AttrList);

SET OF는 정렬되고 SEQUENCE OF는 정렬되지 않습니다

가장 자주 혼동되는 구분이며, 거꾸로 이해하는 것은 무시하는 것만큼 해롭습니다. SEQUENCE OF는 순서 있는 컬렉션입니다. 그 순서는 값의 일부라서 절대 정렬해서는 안 됩니다. SET OF는 값으로서 순서가 없으므로, DER은 인코딩을 유일하게 만들기 위해 표준 순서를 부과합니다. SEQUENCE OF를 정렬하면 데이터가 손상되고, SET OF를 정렬하지 않은 채 두면 DER이 아닌 것이 나옵니다

  • 정렬해야 하는 SET OF: SignerInfo의 SignedAttributes와 UnsignedAttributes, digestAlgorithms 집합, SignedData의 certificates와 crls 집합, signerInfos 집합, 개별 Attribute 안의 값 집합, X.501 Name의 각 RelativeDistinguishedName 안의 속성 집합
  • 절대 정렬하지 않는 SEQUENCE OF: Name을 구성하는 RDN 목록, SigningCertificateV2의 certs 필드, 확장 목록, 그 안의 general-name 목록

RDN은 한 단계 위의 SEQUENCE OF 안에 앉아 있는 SET OF이므로, distinguished name은 두 겹의 중첩 안에 두 규칙을 모두 담습니다. 그 배치는 왜 단일 멤버 집합이 그렇게 관대한지도 설명합니다. 요소가 하나인 집합은 자명하게 정렬되어 있고, 그래서 BuildSignedAttrs 안에서 W.SetOf으로 만든 속성 값 집합은 작성된 그대로 올바랐고 변경이 필요 없었습니다. 버그는 집합이 실제로 멤버를 둘 이상 가질 때만 살고, 다중 값 RDN은 충분히 드물어서 대부분의 이름 인코더가 같은 실수를 수년간 저지르고 지나갑니다

이것이 다시 나타나는 것을 막는 방법

판정이 아니라 바이트에 대해 어서트하세요. 가장 값싼 영속적 검사는 자기 출력을 훑는 자체 감사입니다. 방출된 signedAttrs를 파싱해 각 멤버를 라이터가 쓰는 것과 같은 부호 없는 옥텟 문자열 비교로 이전 멤버와 비교하고, 어느 쌍이라도 순서가 어긋나면 빌드를 실패시킵니다. 마이크로초 안에 끝나고, 외부 라이브러리가 필요 없으며, 검증 왕복과 달리 스펙이 실제로 진술하는 속성을 테스트합니다

// SET OF의 멤버를 훑으며 X.690 11.6 순서를 확인합니다
// Members는 SetOfSorted에 건네진 목록이거나, 방출된
// 31 xx / A0 xx 블록을 다시 파싱한 결과입니다
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// 서명 테스트에서 "우리 검증기가 유효하다고 했다"에 멈추지 마세요
// 서명자가 해시한 바이트를 고정하세요
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

한계 두 가지를 솔직하게 밝힐 가치가 있습니다. signedAttrs를 정렬하는 것만으로 서명이 스스로 상호운용되지는 않습니다. 컨테이너의 다른 모든 것도 버텨야 하고, 그 외에는 온전한 PAdES 서명을 검증기가 거부하는 이유는 그 자체의 논의를 채울 만큼 다양하기 때문입니다. 그리고 정렬된 SET OF는 쓰는 것의 속성이지 읽는 것의 속성이 아닙니다. PDFium 컴포넌트는 signedAttrs가 정렬되지 않은 채 도착하는 수신 서명도 여전히 받아들여야 합니다. 그런 파일이 실제 세상에 존재하고, RFC 5652 절 5.4는 검증기에게 거부가 아니라 재인코딩하라고 말하기 때문입니다. 늘 그렇듯 출력에는 엄격하게, 입력에는 관대하게입니다. PAdES B-B 워크스루가 설명하는 DER 라이터, CMS 빌더, 서명 경로는 모두 읽을 수 있는 Pascal 소스로 PDFium Delphi 컴포넌트와 함께 제공됩니다. 서명 라이브러리에서 소스를 제공하는 이유가 바로 그것입니다. 유럽 어딘가의 검증기가 여러분의 문서에 반대하면, 티켓을 제기하고 기다리는 대신 코드가 방출한 정확한 바이트와 그것이 위반한 정확한 절을 읽을 수 있습니다