기술 문서

PDFium Delphi의 RSASSA-PSS-params 인코딩

PDFium Component 버전 3.114.20은 Windows CNG, macOS Keychain, PKCS#11 세 PAdES 서명 백엔드 모두에서 RSASSA-PSS-params 인코딩을 고쳤습니다. RFC 4055 §3.1은 RSASSA-PSS-params의 모든 필드에 [0]부터 [3]까지의 명시적 컨텍스트 지정 태그를 부여하는데, 백엔드들은 saltLength를 태그 없는 보편 INTEGER로 내보내면서 기본값과 같은 trailerField도 함께 써 내고 있었습니다. 서명 바이트는 내내 올바랐습니다. 그것을 기술하는 AlgorithmIdentifier가 그렇지 않았고, 그것만으로도 검증기가 서명을 거부하기에 충분합니다

답답한 부분은 버그가 숨는 위치입니다. CMS 서명에는 암호 연산과, 그 연산이 어떻게 수행되었는지 검증기에 알려 주는 ASN.1, 이렇게 두 부분이 있습니다. 앞을 맞히고 뒤를 틀리면 명세를 따르는 어떤 도구도 위조와 구별할 수 없는 문서가 됩니다. 이 글은 그 두 번째 부분에만 관한 것입니다. RSASSA-PSS-params가 어떻게 태그되어야 하는지, 세 백엔드가 같은 방식으로 어떻게 틀렸는지, 그리고 바로잡은 DER이 TDerWriter 기준으로 어떤 모습인지입니다

바이트가 올바른 RSASSA-PSS 서명을 검증기가 거부하는 이유

RSASSA-PSS는 검증기가 서명 자체에서 파라미터를 복원할 수 없는 유일한 RSA 방식이기 때문입니다. PKCS#1 v1.5 패딩은 OID sha256WithRSAEncryption에 완전히 결정되므로 파라미터가 빈 NULL이고 틀릴 것이 없습니다. PSS는 해시 함수와 자체 해시를 가진 마스크 생성 함수, 솔트 길이로 매개변수화되며, RFC 8017 §A.2.3은 세 가지를 모두 열어 둡니다. 서명자가 그것들을 고르고, AlgorithmIdentifier가 그것들을 나르며, 검증기는 EMSA-PSS-VERIFY를 시작하기도 전에 그것들을 정확히 재현해야 합니다

그래서 PDFium Component가 SHA-256, SHA-256 위의 MGF1, 32바이트 솔트로 서명할 때 그 세 사실은 여기서 DER 인코딩을 거치고 다른 구현에서 DER 디코딩을 거쳐 살아남아야 합니다. 검증기가 파싱할 수 없는 파라미터 블록은 모듈러 거듭제곱이 일어나기 전에 검증을 끝냅니다. 검증기가 다르게 파싱하는 블록은 더 나쁩니다. RFC 4055 §3.1이 saltLength에 기본값 20을 주기 때문입니다. 인식하지 못하는 필드를 건너뛰는 디코더는 그 기본값에 놓이고, 32로 계산된 서명에 대해 20바이트 솔트로 EMSA-PSS-VERIFY를 돌려, 문제가 키가 아니라 메타데이터라는 힌트 없이 잘못된 서명이라고 보고합니다. 두 결과 모두 3.114.19 인코딩이 만들어 낸 것이고 검증기가 얼마나 엄격했는지에 따라 갈렸으며, 어느 쪽도 AlgorithmIdentifier를 가리키지 않습니다

RFC 4055 §3.1이 RSASSA-PSS-params에 실제로 요구하는 것

RFC 4055 §3.1은 RSASSA-PSS-params를 네 필드의 SEQUENCE로 정의하며, 각 필드는 명시적 컨텍스트 지정 태그와 DEFAULT 값을 지닙니다

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

DER에서 명시적 태깅은 각 필드가 구성형 컨텍스트 지정 TLV로 감싸이고 그 안에 값의 보편 인코딩이 중첩된다는 뜻입니다. [0]은 A0, [1]은 A1, [2]는 A2, [3]은 A3입니다. 모든 필드가 태그되는 이유는 모든 필드가 기본값을 통해 선택적이기 때문입니다. 태그가 없으면 디코더는 AlgorithmIdentifier 하나를 담은 SEQUENCE가 hashAlgorithm인지 maskGenAlgorithm인지 구별할 수 없습니다. 둘 다 SEQUENCE 타입이기 때문입니다. 태그가 있으면 어떤 이웃이 존재하든 태그 번호가 필드를 식별합니다. PDFium Component가 내보내는 값은 ETSI TS 119 312 §7의 프로파일을 따릅니다. SHA-256, SHA-256을 쓰는 MGF1, 다이제스트 길이와 같은 솔트이며, 각 플랫폼 서명 호출이 받는 것과 정확히 일치합니다. NCryptSignHash에는 cbSalt가 32인 BCRYPT_PSS_PADDING_INFO, PKCS#11 메커니즘에는 sLen이 32인 CK_RSA_PKCS_PSS_PARAMS, Security 프레임워크에는 SHA-256 다이제스트 서명 PSS 알고리즘입니다

RFC 4055에 따른 RSASSA-PSS-params 도해: hashAlgorithm A0, maskGenAlgorithm A1, saltLength A2가 DEFAULT 값을 지닌 명시적 컨텍스트 태그를 지니고, ETSI 프로파일은 SHA-256, SHA-256을 쓰는 MGF1, 솔트 32를 내보내며, trailerField A3는 trailerFieldBC와 같아서 DER이 아예 생략합니다
모든 필드가 기본값을 통해 선택적이기 때문에 모든 필드가 태그되며, 그래서 인코더가 어떤 이웃을 빼놓든 태그 번호가 필드를 식별합니다

세 백엔드가 같은 실수를 한 방식

3.114.19 인코딩은 처음 두 필드에 태그를 붙이고 마지막 둘을 맨몸으로 두었으며, TWinCmsSigner, TKeychainCmsSigner, TPkcs11CmsSigner가 똑같았습니다. 그 대칭은 우연이 아닙니다. 셋 모두 FPdfCms.pas의 ICmsSigner 인터페이스를 구현하고, 각자의 GetSignatureAlgorithmParams 본문이 하나의 템플릿으로 작성되었습니다. 그 템플릿은 이랬습니다

// 3.114.20 이전: [0]과 [1]은 태그됨, [2]와 [3]은 아님
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // [2] EXPLICIT가 필요한 자리의 맨몸 INTEGER
  W.IntegerOf(1)));     // trailerField: DEFAULT와 같으므로 없어야 함

그 SEQUENCE를 걷는 디코더는 A0를 보고 해시 알고리즘을 읽고, A1을 보고 마스크 생성 함수를 읽은 뒤 02 01 20을 만납니다. 그것은 보편 INTEGER이고, RSASSA-PSS-params에는 태그 없는 INTEGER 멤버가 어디에도 없습니다. 엄격한 디코더는 거기서 멈춥니다. 관대한 디코더는 인식하지 못하는 요소를 건너뛰고 A2를 결코 찾지 못해 saltLength에 기본값 20을 배정한 뒤, 두 번째로 떠돌이 INTEGER 02 01 01을 만나 같은 문제를 다시 겪습니다. 어느 경로도 32바이트 솔트에 도달하지 않습니다. 공유 템플릿은 맞을 때는 효율적이고 틀릴 때는 세 번 틀리는 똑같이 효율적인 방법이며, 그래서 수정이 한 커밋에서 세 유닛 모두에 들어갔고 세 메서드 본문이 나중에도 구조적으로 동일합니다. 미래의 백엔드는 그것을 다시 도출하지 말고 이 중 하나에서 블록을 복사해야 합니다. 실수가 난 곳이 정확히 도출이기 때문입니다

PDFium Component의 3.114.19 DER 버그 도해: A0와 A1 뒤에서 파라미터가 A2 명시 태그가 있어야 할 자리에 맨몸 02 01 20을 만나, 엄격한 디코더는 멈추고 관대한 디코더는 기본 20바이트 솔트로 서명했으며, 3.114.20은 32바이트 솔트를 A2로 감쌉니다
RSASSA-PSS-params에는 태그 없는 INTEGER 멤버가 없으므로 떠돌이 바이트는 파싱 실패이거나 기본 솔트 길이로의 조용한 폴백이었고, 어느 경로도 서명자의 32에 도달하지 못했습니다

trailerField를 [3]으로 태그하지 않고 생략하는 이유

X.690 §11.5가 값이 DEFAULT와 같은 구성 요소를 DER 인코더가 인코딩해서는 안 된다고 하고, trailerField의 DEFAULT는 정수 1인 trailerFieldBC이기 때문입니다. 예전 코드에 대한 뻔한 수정, 즉 맨몸 W.IntegerOf(1)을 W.ContextSpecific(3, W.IntegerOf(1), True)로 바꾸는 것은 관대한 BER 디코더는 받아들이고 엄격한 DER 디코더는 거부할 자격이 있는 블록을 만듭니다. 값이 틀린 것이 아니라 존재 자체가 틀립니다. 같은 규칙이 나머지 세 필드가 존재하는 이유이기도 합니다. SHA-256은 기본값 sha1이 아니고, SHA-256을 쓰는 MGF1은 기본값 mgf1SHA1이 아니며, 32는 기본값 20이 아닙니다. 백엔드가 SHA-1과 20바이트 솔트로 서명하고 있었다면 RFC 4055 §3.1은 파라미터를 빈 SEQUENCE 30 00으로 접었을 것이고, 검증기가 기대하는 것은 NULL이 아니라 그 빈 SEQUENCE입니다. PDFium Component는 그 값으로 서명하지 않으므로 그 형태를 결코 내보내지 않지만, 파라미터 없음이 항상 05 00이라고 가정하는 사람을 잡아내는 경우가 바로 그 경우입니다

이것이 특히 서명에서 중요한 DER과 BER의 차이입니다. BER은 인코더가 기본값을 가진 구성 요소를 포함하도록 허용하고, DER은 금지합니다. DER은 하나의 값이 정확히 하나의 인코딩을 갖도록 존재하며, 법적으로 두 가지 인코딩이 가능한 구조에 대한 서명은 논쟁의 여지가 있는 서명이기 때문입니다. CMS signedAttrs 안의 모든 것이 그 이유로 DER이고, 파라미터 블록은 바깥 signatureAlgorithm뿐 아니라 cmsAlgorithmProtection 속성을 통해 signedAttrs 안쪽으로도 이동하므로 면제받지 않습니다

PSS 수정에서의 X.690 11.5 규칙 도해: DEFAULT인 trailerFieldBC와 같은 trailerField는 없어야 합니다. 태그된 A3 래퍼는 엄격한 DER이 거부하는 인코딩이고, saltLength 32는 기본값 20과 다르므로 A2로 반드시 존재해야 합니다
DER은 하나의 값이 정확히 하나의 인코딩을 갖도록 존재하며, 기본값과 같은 구성 요소는 이미 가장 짧은 인코딩, 즉 아예 나타나지 않는 형태를 갖고 있습니다

바로잡은 TDerWriter 인코딩

이제 PDFium Component는 FPdfAsn1.pas의 TDerWriter.ContextSpecific을 세 번 호출해 파라미터를 만듭니다. 기본값이 아닌 필드마다 한 번씩이고, 각각 Constructed 인수를 True로 두어 명시 태그 래퍼를 만들며, 트레일러 필드에 대한 줄은 아예 없습니다. 아래는 OID를 펼쳐 쓴 TWinCmsSigner.GetSignatureAlgorithmParams의 본문입니다. Keychain과 PKCS#11 유닛은 같은 값을 OID_SHA256, OID_MGF1, OID_RSASSA_PSS로 씁니다

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1은 네 필드 모두에 태그를 붙인다. saltLength는 [2]이며,
      // 여기서 맨몸 INTEGER는 다른 필드의 시작으로 읽힌다. trailerField는
      // DEFAULT 1인 [3]이고, X.690 11.5는 기본값과 같은 값을
      // 인코딩하는 것을 금지하므로 아예 생략한다
      Result := W.Sequence(Concat3(
        W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
        W.ContextSpecific(1, W.Sequence(ConcatBytes(
          W.OID('1.2.840.113549.1.1.8'),          // id-mgf1
          W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
        W.ContextSpecific(2, W.IntegerOf(32), True)));
    finally
      W.Free;
    end;
  end
  else
    Result := nil;   // PKCS#1 v1.5와 ECDSA: AlgIdWithParams가 NULL을 씀
end;

주변 기계장치의 세부 두 가지가 중요합니다. TDerWriter.AlgId는 NULL 파라미터를 가진 AlgorithmIdentifier를 만드는데, 중첩된 hashAlgorithm과 MGF1 내부 해시에 대해 RFC 4055 §2.1이 인코더에게 생성하라고 지시하는 것이 그것입니다. 그리고 FPdfCms.pas의 CMS 빌더는 서명 OID를 TDerWriter.AlgIdWithParams를 통해 이 바이트들과 짝지으며, 파라미터가 nil이면 NULL로 대체합니다. 그래서 psRsaPkcs1v15와 psEcdsa는 그냥 nil을 반환하며 결코 영향을 받지 않았고, 1.2.840.113549.1.1.10(id-RSASSA-PSS)이 셋 중 실제 파라미터 블록을 지닌 유일한 서명 OID입니다. SHA-256 프로파일에 대한 결과 바이트는 고정되어 있고 눈으로 확인할 만큼 짧습니다. 바깥 30 34 SEQUENCE가 15바이트 SHA-256 AlgorithmIdentifier를 감싼 A0 0F, 자체 파라미터가 같은 SHA-256 AlgorithmIdentifier인 28바이트 MGF1 AlgorithmIdentifier를 감싼 A1 1C, 그리고 솔트를 위한 A2 03 02 01 20을 담습니다. signatureAlgorithm 덤프에서 파라미터 SEQUENCE의 최상위에 A2 안이 아니라 02 01 20이 보인다면 3.114.19 인코딩을 보고 있는 것입니다

테스트 스위트가 잘못된 AlgorithmIdentifier를 잡지 못한 이유

PAdES 테스트가 sha256WithRSAEncryption를 보고하고 GetSignatureAlgorithmParams에서 nil을 반환하는 가짜 서명자를 통해 CMS 빌더를 구동하기 때문에, PSS 파라미터 블록은 테스트에서 아예 만들어지지 않았습니다. 인증서 저장소나 Keychain, 토큰 없이 돌아야 하는 테스트에는 합리적인 설계이고, 정확한 모양의 사각지대를 갖습니다. 진짜 백엔드만 만들어 내는 것은 진짜 백엔드만이 행사한다는 것입니다. 두 번째 층이 더 흥미롭습니다. PDFium Component는 파라미터를 포함한 서명 AlgorithmIdentifier를 RFC 6211의 cmsAlgorithmProtection 서명 속성 안에도 넣는데, 검증기는 그 사본을 바깥 signatureAlgorithm과 비교합니다. 두 사본 모두 같은 호출에서 나왔으므로 완벽히 일치했고 모든 내부 일관성 검사를 통과했습니다. 인코딩은 자기 일관적이었고 틀렸습니다. 구조를 자기 자신과 아무리 비교해도 드러나지 않는 종류의 버그이며, 구조만 다른 같은 교훈이 CMS signedAttrs와 DER SET OF 정렬에 있습니다. 한 순서로 해시하고 다른 순서로 내보낸 SET은 외부 검증기가 해시를 다시 계산하기 전까지 멀쩡해 보였습니다

이런 부류의 버그를 잡는 것은 인코더 작성자가 쓰지 않은 디코더를 진짜 백엔드의 실제 출력에 돌리는 것입니다. PDFium Component의 Windows 검증 경로는 라이브러리 자체 리더가 아니라 CryptoAPI를 거치며, 거기서 거부된 PSS 서명이 파라미터로 되돌아가게 했습니다. 구현이 다른 구현에게 읽히도록 내보내는 ASN.1은 적어도 자기 통제 밖의 디코더를 한 번 왕복할 가치가 있고, 구조에 기본값과 태그가 많을수록 그 왕복의 가치가 커집니다

PSS 이야기의 나머지와 이 수정의 관계

이 수정은 PAdES 서명에서 PSS가 틀릴 수 있는 다른 두 지점과 독립적이며, 그것들을 분리해 두면 디버깅이 짧아집니다. macOS 백엔드는 특정 키나 구형 시스템이 PSS를 거부하면 PKCS#1 v1.5로 다운그레이드할 수 있고, AlgorithmIdentifier는 그 다운그레이드를 따라야 합니다. 그것은 능력 문제이고 macOS Keychain ID로 PAdES 서명하기에서 다룹니다. PKCS#11 백엔드는 정수 폭 불일치 때문에 토큰이 다르게 읽는 CK_RSA_PKCS_PSS_PARAMS 레이아웃을 토큰에 넘길 수 있습니다. 그것은 ABI 문제이고 CK_ULONG과 PKCS#11 패킹 함정에서 다룹니다. 이 글은 세 번째 실패, 키가 응하고 토큰이 올바른 바이트를 계산했는데 결과를 기술하는 DER이 RFC 4055 §3.1을 따르지 않은 경우에 관한 것입니다

PSS를 선언하는 서명자는 v1.5 서명자가 결코 지지 않았던 의무를 집니다. 자기 파라미터를 다른 구현이 같은 세 값으로 디코드하는 형태로 기술하는 것입니다. RFC 4055 §3.1이 태그를 고정하고, X.690 §11.5가 어떤 필드가 나타날 수 있는지 고정하며, ETSI TS 119 312 §7이 선택할 만한 값을 고정합니다. PDFium Delphi 컴포넌트의 세 백엔드가 모두 소스로 배포되므로, 위의 GetSignatureAlgorithmParams 본문은 믿고 넘기기보다 읽고 덤프해 자기 검증기와 비교할 수 있는 것입니다