기술 문서

PDFium Delphi의 RSASSA-PSS-params 인코딩

A0 0F … A1 1C … 02 01 20 02 01 01로 끝나는 RSASSA-PSS signatureAlgorithm 헥스 덤프는 완전해 보입니다. SHA-256, MGF1, 32바이트 솔트, 1의 트레일러. 그러나 엄격한 디코더는 그 파라미터 블록을 곧장 거부하고, 관대한 디코더는 솔트 길이를 20으로 읽습니다. 어느 쪽이든 RSA 산술이 올바른 서명이 무효로 돌아옵니다. RFC 4055 §3.1이 이유를 설명합니다. RSASSA-PSS-params의 모든 필드는 명시적 컨텍스트 태그를 지니므로 솔트는 A2 안에 있어야 하고, DER 아래에서는 기본값과 같은 trailerField가 아예 나타나서는 안 됩니다

이 때문에 CMS 서명에는 서로 독립적으로 실패할 수 있는 두 부분이 생깁니다. 하나는 암호 연산이고, 다른 하나는 그 연산이 어떻게 수행되었는지 검증기에 알려 주는 ASN.1입니다. 첫째를 맞히고 둘째를 틀리면, 명세를 따르는 검증기는 여러분의 문서와 위조를 구별할 방법이 없습니다. 이 글이 다루는 것은 ASN.1 쪽입니다

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

RSASSA-PSS는 검증기가 알고리즘 OID에서 파라미터를 도출할 수 없는 유일한 RSA 방식이기 때문입니다. PKCS#1 v1.5는 sha256WithRSAEncryption으로 완전히 못 박히므로 그 파라미터는 맨몸 NULL입니다. PSS는 메시지 해시와 자기만의 해시를 지닌 마스크 생성 함수, 솔트 길이로 매개변수화되며, RFC 8017 §A.2.3은 이 셋을 모두 서명자에게 맡깁니다. AlgorithmIdentifier가 서명자의 선택을 나르고, 검증기는 EMSA-PSS-VERIFY가 돌기 전에 그것들을 정확히 재현해야 합니다

검증기가 파싱할 수 없는 블록은 모듈러 거듭제곱이 일어나기 전에 검증을 끝냅니다. 검증기가 다르게 파싱하는 블록은 더 나쁩니다. RFC 4055 §3.1은 saltLength에 기본값 20을 주므로, 솔트 필드를 끝내 찾지 못한 디코더는 32로 계산된 서명에 대해 20바이트 솔트로 EMSA-PSS-VERIFY를 돌립니다. 그 결과는 잘못된 서명으로 보고되고, 아무것도 메타데이터를 가리키지 않습니다. 검증기가 얼마나 엄격한지가 두 실패 중 어느 쪽을 보게 될지 결정하며, 어느 오류 메시지도 AlgorithmIdentifier를 언급하지 않습니다

RSASSA-PSS-params의 네 필드와 그 태그

RFC 4055 §3.1은 RSASSA-PSS-params를 네 필드의 SEQUENCE로 정의하며, 각 필드는 명시적 컨텍스트 지정 태그와 DEFAULT 값을 지닙니다. DER에서 명시적 태깅은 각 필드를 구성형 컨텍스트 TLV, 곧 A0부터 A3까지로 감싸고 그 안에 필드 자신의 보편 인코딩을 중첩한다는 뜻입니다:

필드태그(DER 바이트)DEFAULTETSI TS 119 312 §7 값인코딩 여부
hashAlgorithm[0] (A0)sha1SHA-256예
maskGenAlgorithm[1] (A1)mgf1SHA1SHA-256을 쓰는 MGF1예
saltLength[2] (A2)2032(다이제스트 길이)예
trailerField[3] (A3)trailerFieldBC (1)1아니요, 기본값과 같음

태그가 존재하는 이유는 모든 필드가 기본값을 통해 선택적이기 때문입니다. hashAlgorithm과 maskGenAlgorithm은 둘 다 AlgorithmIdentifier SEQUENCE라서, 태그가 없다면 SEQUENCE 하나만 담은 파라미터 블록을 본 디코더는 그것이 둘 중 어느 쪽인지 알 수 없습니다. 태그가 있으면 인코더가 어느 이웃을 빼놓든 태그 번호가 필드 이름을 밝혀 줍니다

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

솔트는 다이제스트 길이와 같게 고르고 거기 고정하세요. 어떤 API는 "최대 솔트"나 "솔트 길이 자동"을 제공하는데, 그 결과는 키 크기에 좌우되는 값입니다. RFC 8017 아래에서 합법적이지만, 그러면 AlgorithmIdentifier가 키마다 바뀌는 숫자를 진술해야 하고, SHA-256에는 32바이트 솔트가 ETSI TS 119 312 §7이 어차피 요구하는 값입니다. PDFium Component의 내장 서명 백엔드는 모두 32로 고정하고 같은 숫자를 플랫폼 호출에 넘깁니다. Windows의 NCryptSignHash에 주는 BCRYPT_PSS_PADDING_INFO의 cbSalt, PKCS#11 토큰의 CK_RSA_PKCS_PSS_PARAMS 안의 sLen, OpenSSL 아래의 EVP_PKEY_CTX_set_rsa_pss_saltlen, macOS에서 kSecKeyAlgorithmRSASignatureDigestPSSSHA256이 함축하는 다이제스트 길이 솔트가 그것입니다

자신만의 ICmsSigner를 위한 GetSignatureAlgorithmParams 작성하기

PDFium Component에서 모든 서명 백엔드는 FPdfCms.pas의 ICmsSigner 인터페이스를 구현하고, 파라미터 블록은 한 메서드, GetSignatureAlgorithmParams에서 나옵니다. CMS 빌더는 그 결과를 GetSignatureAlgorithmOid의 OID와 TDerWriter.AlgIdWithParams로 짝지습니다. 여러분은 내장 백엔드가 커버하지 않는 사인어 — 네트워크 HSM, 서명 서비스, 스마트 카드 드라이버 — 를 ConfigurePadesSignerProvider로 끼워 넣을 때 이 메서드를 만나게 됩니다. 사람들이 가장 먼저 쓰는 인코딩은 감싸질 것이 뻔한 두 AlgorithmIdentifier에 태그를 붙이고 두 정수를 맨몸으로 둡니다:

// 흔한 첫 시도: [0]과 [1]은 태그하고 [2]와 [3]은 아님
Result := W.Sequence(Concat4(
  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'),
    W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
  W.IntegerOf(32),      // [2] EXPLICIT가 필요한 자리의 맨몸 INTEGER
  W.IntegerOf(1)));     // DEFAULT와 같은 trailerField: 없어야 함

디코더가 걷는 방식으로 그것을 걸어 보세요. A0를 보고 해시를 읽고, A1을 보고 마스크 생성 함수를 읽은 뒤, 02 01 20, 곧 보편 INTEGER를 만납니다. RSASSA-PSS-params에는 태그 없는 INTEGER 멤버가 어디에도 없으므로 엄격한 디코더는 멈춥니다. 관대한 디코더는 모르는 요소를 건너뛰고, A2를 끝내 찾지 못해 기본 솔트 20을 배정한 뒤, 두 번째 떠돌이 INTEGER 02 01 01에 걸려 넘어집니다. 어느 경로도 32에 닿지 않습니다

RSASSA-PSS-params 태깅 함정 도해: A0와 A1 뒤에서 태그가 잘못된 블록은 A2 명시 태그가 있어야 할 자리에서 맨몸 02 01 20을 만나고, 엄격한 디코더는 멈추고 관대한 디코더는 기본 20바이트 솔트로 폴백하며, 올바른 인코딩은 32바이트 솔트를 A2로 감쌈
RSASSA-PSS-params에는 태그 없는 INTEGER 멤버가 없으므로 떠돌이 바이트는 파싱 실패이거나 기본 솔트 길이로의 조용한 폴백이며, 어느 경로도 서명자의 32에 닿지 못합니다

표준에 맞는 버전은 FPdfAsn1.pas의 TDerWriter.ContextSpecific을 기본값이 아닌 필드마다 한 번씩, 세 번 호출하며, 각각 Constructed를 True로 두어 결과가 명시적 래퍼가 되게 합니다. 트레일러 필드의 줄은 없습니다. Windows CNG, macOS Keychain, PKCS#11, OpenSSL 백엔드가 내보내는 것과 같은 블록이며, 여기서는 커스텀 서명자가 들고 가는 모양으로 적었습니다:

uses
  System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;

type
  TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
  private
    FUsePss: Boolean;
  public
    function GetDigestAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmParams: TBytes;
    function GetSignerId: TCmsSignerId;
    function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
  end;

// 여러분의 클래스. 나머지 네 메서드는 HSM과 대화하며 생략했습니다
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if not FUsePss then
    Exit(nil);   // v1.5와 ECDSA: AlgIdWithParams가 nil에 NULL을 씁니다
  W := TDerWriter.Create;
  try
    // RFC 4055 3.1: [0] 해시, [1] 자체 해시를 지닌 MGF1, [2] 솔트.
    // [3] trailerField = 1은 DEFAULT와 같으므로 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;

TDerWriter.AlgId는 NULL 파라미터를 지닌 AlgorithmIdentifier를 쓰는데, 중첩된 SHA-256 식별자에 대해 RFC 4055 §2.1이 인코더에게 생성하라고 말하는 것이 바로 그것입니다. 결과 바이트는 눈으로 확인할 만큼 짧습니다. 바깥 30 34, 15바이트 SHA-256 AlgorithmIdentifier를 감싼 A0 0F, 28바이트 MGF1 AlgorithmIdentifier를 감싼 A1 1C, 그리고 솔트의 A2 03 02 01 20. GetSignatureAlgorithmParams에 적은 솔트는 HSM이 실제로 쓰는 솔트와 반드시 일치해야 합니다. 서비스가 "SHA-256을 쓰는 PSS"만 알려 주고 솔트 길이를 말해 주지 않는다면, 선적 전에 알아 내세요. AlgorithmIdentifier는 그것에 관한 주장이니까입니다

trailerField는 왜 [3]으로 태그하지 않고 생략하는가

X.690 §11.5는 값이 자기 DEFAULT와 같은 구성 요소를 DER 인코더가 인코딩해서는 안 된다고 말합니다. trailerField의 DEFAULT는 trailerFieldBC, 곧 정수 1이고, RFC 8017이 정의하는 PSS는 그 트레일러만 사용합니다. 그래서 첫 시도의 뻔한 수리, 곧 W.IntegerOf(1)을 W.ContextSpecific(3, W.IntegerOf(1), True)로 바꾸는 것은 A3 03 02 01 01을 낳는데, 관대한 BER 디코더는 받아들이지만 엄격한 DER 디코더는 거부할 자격이 있습니다. 값은 옳습니다. 존재가 옳지 않습니다

같은 규칙이 나머지 세 필드가 왜 나타나는지도 설명합니다. SHA-256은 sha1이 아니고, SHA-256을 쓰는 MGF1은 mgf1SHA1이 아니며, 32는 20이 아닙니다. SHA-1과 20바이트 솔트를 쓰는 서명자는 블록 전체를 빈 SEQUENCE, 30 00으로 접을 것입니다. 이 모양은 "파라미터 없음"이 항상 05 00이라고 가정하는 사람을 넘어뜨립니다. CMS의 id-RSASSA-PSS에서 파라미터 필드는 필수이고, 빈 SEQUENCE는 "모두 기본값"을 뜻하며, NULL은 유효한 RSASSA-PSS-params 값이 아닙니다

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

BER은 인코더가 기본값 구성 요소를 넣는 것을 허용하고, DER은 금지합니다. DER은 하나의 값이 정확히 하나의 인코딩을 갖도록 존재하는데, 법적으로 두 가지 인코딩이 가능한 구조에 대한 서명은 두고두고 논쟁거리가 되기 때문입니다. 파라미터 블록은 여기서 DER을 피할 수 없습니다. SignerInfo의 바깥 signatureAlgorithm에 앉아 있고, RFC 6211의 cmsAlgorithmProtection 속성을 통해 서명된 속성 안으로 복사되어 모든 바이트가 서명으로 덮입니다

여러분이 빌드하지 않은 CMS 점검하기

cmsAlgorithmProtection 안쪽의 그 사본은 태그가 틀린 블록을 잡지 못합니다. 속성과 바깥 signatureAlgorithm은 같은 호출에서 나오므로 언제나 일치하고, 구조를 자기 자신과 비교하는 검사는 통과합니다. 문제를 잡는 것은 파라미터 필드를 필드 단위로 읽는 디코더입니다. PDFium Component의 FPdfPades.pas에 있는 InspectPadesSignatureAlgorithm이 그것을 합니다. A0부터 A3까지만, 각각 최대 한 번, 오름차순으로만 받아들이고, 없는 것은 RFC 4055 기본값으로 채우며, 실효값을 TPadesRsaPssParameters로 보고합니다. 블록 안의 맨몸 INTEGER는 SignaturePss.Valid를 False로 남기고, EvaluatePadesSignatureAlgorithm은 이어서 PolicyStatus를 pcsInvalid로 놓습니다. SignerInfo 다이제스트 알고리즘과 다른 PSS 해시도 거부하는데, sha1로의 관대한 폴백이 만들어 내는 바로 그것입니다

이것이 가장 중요해지는 곳은 원격 서명입니다. 외부 서비스가 완성된 CMS를 돌려주고 CompletePadesRemoteSignature가 그것을 준비된 PDF에 박어 넣는 구조입니다. 넣기 전에 그 blob을 검사하세요. 그 지점에서 거부하는 것은 API 호출 한 번입니다. 고객의 검증기에서 알게 되는 것은 서비스 운영자와의 재서명 라운드입니다:

uses
  System.SysUtils, FPdfPades;

// CompletePadesRemoteSignature 전에 여러분이 직접 추가할 점검 예시
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
  OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
  Info: TPadesSignatureAlgorithmInfo;
  Values: string;
begin
  Result := False;
  Why := '';
  // nil: 검사기가 CMS 안쪽에서 서명자 인증서를 찾게 둡니다
  if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
  begin
    Why := 'CMS SignerInfo could not be parsed';
    Exit;
  end;
  if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
  begin
    if not Info.SignaturePss.Valid then
    begin
      Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
      Exit;
    end;
    // 실효값, 기본값 포함: 빠진 A2는 여기서 20으로 나타납니다
    Why := Format('hash %s, MGF hash %s, salt %d', [
      string(Info.SignaturePss.HashAlgorithmOid),
      string(Info.SignaturePss.MaskGenHashAlgorithmOid),
      Info.SignaturePss.SaltLength]);
  end;
  if Info.PolicyStatus = pcsInvalid then
    Exit;   // Why는 로그용 디코딩 값을 아직 쥐고 있습니다
  Values := Why;
  Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
  if Result then
    Why := Values;   // 보호 검사는 성공 시 Why를 지웁니다
end;

성공할 때조차 Why 문자열을 로그에 남기세요. SHA-256 해시 옆의 솔트 길이 20은 서비스 자체 로그가 뭐라 하든 A2가 증발한 파라미터 블록의 지문입니다. 코드 없이 빠르게 들여다보려면 openssl asn1parse -inform DER -in signature.p7s가 트리를 인쇄합니다. id-RSASSA-PSS OID 아래에서 여러분이 보고 싶은 것은 20 헥스의 INTEGER를 감싼 cont [ 2 ]이고, cont [ 3 ]은 전혀 없어야 합니다. 그 SEQUENCE 안의 최상위 INTEGER는 첫 코드 블록의 태그 틀린 인코딩입니다

PSS 이야기의 나머지와 이 글의 위치

파라미터 인코딩은 PAdES 서명에서 PSS가 틀릴 수 있는 세 갈래 길 중 하나이고, 갈라 두면 디버깅이 짧아집니다. macOS에서는 특정 키나 구형 시스템이 PSS를 제공하지 않을 수 있고, 그러면 백엔드가 PKCS#1 v1.5로 서명하고 그에 맞게 sha256WithRSAEncryption을 선언합니다. 그 능력 문제는 macOS Keychain ID로 PAdES 서명하기에서 다룹니다. PKCS#11 토큰은 호출자가 적은 것과 다른 정수 폭으로 CK_RSA_PKCS_PSS_PARAMS를 읽을 수 있는데, CK_ULONG과 PKCS#11 패킹 함정이 다루는 ABI 문제입니다. 셋째가 바로 위의 것입니다. 키는 응하고 토큰은 올바른 바이트를 계산하는데, 그것들을 기술하는 DER이 RFC 4055 §3.1을 따르지 않는 경우입니다

서명된 속성 SET에는 자기 일관성 사각지대의 자기만의 버전이 있습니다. 한 순서로 해시하고 다른 순서로 내보낸 SET은 외부 검증기가 해시를 다시 계산하기 전까지 멀쩡해 보입니다. CMS signedAttrs와 DER SET OF 정렬을 보세요. 위의 점검이 끼어드는 준비-원격 서명-완성 사이클은 HSM과 함께하는 원격 PAdES 서명에서 2단계 프로토콜과 완성 단계가 거부하는 것들을 다룹니다

PDFium Delphi 컴포넌트는 서명 백엔드를 소스로 배포하므로, Windows, macOS, PKCS#11, OpenSSL 유닛의 GetSignatureAlgorithmParams 본문은 여러분 서명자의 출력 옆에 놓고 바이트 단위로 비교해 볼 수 있게 읽혀 있습니다