CMS 컨테이너 안의 ECDSA signatureValue는 DER SEQUENCE { INTEGER r, INTEGER s }입니다. Windows CNG 함수 BCryptVerifySignature는 그 어느 쪽도 받아들이지 않습니다. 태그도 길이도 없는 IEEE P1363 고정폭 r || s를 원합니다. 델파이와 C++Builder용 네이티브 VCL PDF 컴포넌트인 HotPDF는 키를 가져오기 전에 엄격한 DER 규칙에 따라 이 둘 사이를 변환합니다
이 변환이 막아주는 실패는 특정하고 사기를 꺾는 종류의 실패입니다. Acrobat은 문서를 열고 초록색 체크 표시를 보여줍니다. 같은 바이트를 순회하는 여러분 자신의 검증기는 무효라고 반환하거나, CNG가 별다른 설명 없이 STATUS_INVALID_SIGNATURE를 돌려줍니다. 서명에는 아무 문제가 없습니다. 잘못된 것은 대략 70바이트의 ASN.1이 64바이트의 원시 정수를 기대하는 API로 전달되었다는 점이며, 이 불일치는 찾아야 한다는 것을 알지 못하면 보이지 않습니다
BCryptVerifySignature가 유효한 ECDSA 서명을 거부하는 이유는 무엇인가
호출 양쪽이 서로 다른 서명 인코딩을 사용하는데, 그 어느 쪽도 이를 알리지 않기 때문입니다. ISO 32000-1 §12.8은 서명 딕셔너리가 /Contents에 CMS 블롭을 담는다고 말하고, RFC 5652 §5.3은 각 SignerInfo의 signatureValue가 서명 알고리즘이 정의하는 내용을 담은 OCTET STRING이라고 말합니다. ECDSA의 경우 그 내용은 SEC 1 DER 구조입니다: 두 개의 INTEGER를 담은 SEQUENCE입니다. 이는 설계상 가변 길이입니다. r과 s는 정수이고 DER은 정수에서 앞쪽의 0 옥텟을 제거하기 때문입니다
IEEE P1363은 정반대 관점을 취합니다. 서명을 두 좌표의 연결로 정의하며, 각각을 곡선 필드의 바이트 폭에 정확히 맞도록 0으로 왼쪽 패딩합니다. P-256 서명은 항상 64바이트입니다. 같은 서명의 DER 인코딩은 보통 70바이트나 71바이트이며 대략 8바이트에서 72바이트 사이 어디든 될 수 있습니다. DER 형식을 그대로 BCryptVerifySignature에 넘기면 길이 검사만으로도 호출은 실패로 끝납니다. 그래서 HotPDF는 검증 이후가 아니라 이전에 정규화를 수행합니다
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
서명 파서가 절대 완화해서는 안 되는 DER 규칙
여기 나열된 모든 거부는 HotPDF가 의도적으로 수행하는 거부이며, 각각은 관대한 파서라면 열어 두었을 경로를 하나씩 닫습니다. 컨버터를 작성할 때의 유혹은 두 개의 INTEGER 노드를 찾아 그 내용을 복사하고 넘어가는 것입니다. 이는 형식이 올바른 입력에서는 동작하지만, 악의적인 입력에 대해서는 유연성 있는 재인코딩의 한 계열을 조용히 받아들입니다. 그래서 HPDFECDSANormalizeSignature는 음수 정수, 즉 첫 내용 옥텟의 최상위 비트가 세팅된 r이나 s를 거부합니다. 유효한 ECDSA 스칼라는 양수여야 하기 때문입니다. 값 전체가 0인 경우도 거부합니다. r = 0이나 s = 0은 결코 정당한 서명이 아니기 때문입니다. 불필요한 선행 0 옥텟도 거부합니다. X.690 §8.3은 정확히 하나만, 그것도 다음 옥텟이 그렇지 않으면 음수로 읽힐 때만 허용하므로, 0x80 미만인 옥텟 앞에 붙은 00은 서명이 아니라 재인코딩입니다. 비최소 길이 헤더도 거부합니다. X.690 §10.1은 가장 적은 옥텟 수로 인코딩된 확정 형식을 요구하므로, 짧은 형식으로 표현될 수 있었던 긴 형식 길이는 같은 의미를 담은 다른 바이트 문자열입니다. 곡선의 좌표 크기보다 넓은 정수도 거부합니다. 그런 값은 필드 원소가 될 수 없기 때문입니다. 그리고 s 뒤에 오는 후행 노드나, 전체 길이가 블롭 전체 길이와 일치하지 않는 바깥쪽 SEQUENCE도 거부합니다
마지막 두 가지는 겉보기보다 중요합니다. SEQUENCE 뒤의 후행 바이트는 고전적인 서명 가변성(malleability) 트릭입니다. 쓰레기 바이트를 덧붙이면 느슨한 검증기는 여전히 유효하다고 말하지만, 그것이 검증한 바이트 문자열은 서명된 바이트 문자열이 아닙니다. 같은 본능이 PKCS#12 파싱에 관한 노트에서 설명한 ASN.1 길이 강화를 이끌었고, 여기서도 같은 본능이 작동합니다. 검증 경로에서는 준수하는 서명자가 만들어낸 적 없는 구조를 받아들이는 것은 배려가 아니라 결함입니다
좌표 폭은 서명이 아니라 곡선에 속한다
HotPDF는 출력 폭을 방금 파싱한 DER의 길이가 아니라 이름 붙은 곡선 OID에서 도출합니다. 이는 변환의 두 번째 절반이며, 미묘하게 잘못하기 쉬운 절반이기도 합니다. RFC 5480 §2.1.1은 인증서의 SubjectPublicKeyInfo 매개변수 안에서 곡선을 식별하며, HPDFECDSACurveFromOID는 HotPDF가 지원하는 세 OID를 매핑합니다: P-256에는 1.2.840.10045.3.1.7, P-384에는 1.3.132.0.34, P-521에는 1.3.132.0.35입니다. 그러면 HPDFECDSACoordinateSize는 32, 48, 또는 66바이트를 반환하고, P1363 버퍼는 그 두 배인 64, 96, 132바이트가 됩니다. 디코딩된 각 정수는 자신의 절반 안에서 오른쪽 정렬되므로, 짧은 r은 이동되지 않고 왼쪽에 0으로 채워집니다. P-521이 사람들을 종종 걸려 넘어지게 만드는 이유는, 521비트가 65.125바이트이고 66으로 올림되어 132바이트 서명이 되기 때문입니다. 2의 거듭제곱에 대한 직관으로는 예측할 수 없는 값입니다. 공개 키는 RFC 5480 §2.2에 따른 비압축 EC 점, 즉 0x04에 이어 X와 Y가 따라오는 형식으로 함께 전달되므로, HotPDF는 CNG를 건드리기 전에 그 길이가 정확히 1 + 2 * CoordinateSize바이트이고 0x04로 시작하는지 확인합니다
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
마지막 매개변수를 주목하십시오. HPDFECDSAVerifyDigest는 하드웨어 토큰이나 원시 r || s를 반환하는 원격 서명 서비스처럼 이미 고정폭 서명을 갖고 있는 호출자를 위해 eseP1363도 받아들입니다. 이 경로 역시 양쪽 절반에 대해 길이 검사와 0이 아님 검사를 강제하므로, 크기는 맞지만 모두 0으로 채워진 버퍼는 프로바이더로 그냥 넘겨지지 않고 거부됩니다
범용 ECDSA 알고리즘 이름이 구형 Windows에서 실패하는 이유는 무엇인가
범용 이름은 여러분이 배포하려는 대상 기반보다 더 최신이기 때문입니다. CNG는 가져온 키로부터 곡선을 추론하는 알고리즘 식별자 ECDSA를 노출하며, 이는 코드를 작성하는 깔끔한 방법이지만, BCryptOpenAlgorithmProvider가 이를 해석하도록 보장되는 것은 비교적 최신 Windows 버전에서뿐입니다. 구형 머신에서는 열기 호출이 실패하고 프로바이더 핸들은 nil로 남으며, 애플리케이션의 모든 ECDSA 검증이 완벽하게 정상인 서명에 대해 미지원이라고 보고합니다. HotPDF는 대신 곡선별 식별자를 열어 이 절벽을 피합니다. ECDSA_P256, ECDSA_P384, ECDSA_P521을 한 번씩 해석하고, 곡선마다 하나의 프로바이더 핸들을 캐싱하며, 유닛 종료 시점에 그것들을 닫습니다. 그러면 각 검증은 값싼 작업만 수행합니다: ECCPUBLICBLOB에서 임시 공개 키를 가져오고, BCryptVerifySignature를 호출하고, 키를 파괴합니다. 반복되는 LoadLibrary도, 반복되는 GetProcAddress도, 서명마다 프로바이더를 열고 닫는 것도 없습니다. 수백 개 문서의 일괄 검증에서는 그 차이가 체감되며, 부하 아래에서 그렇지 않았다면 프로바이더 핸들을 계속 소모했을 서비스 프로세스에서도 마찬가지입니다
결과 코드는 이 구분에 대해 정직합니다. evrProviderUnavailable은 머신이 HotPDF에 프로바이더를 줄 수 없었다는 뜻이고, evrInvalid는 CNG가 STATUS_INVALID_SIGNATURE로 답했다는 뜻입니다. 이 둘을 하나의 실패로 뭉뚱그리는 것이 배포 문제가 위조된 문서로 잘못 보고되는 경위입니다. 환경 실패와 암호학적 실패 사이의 같은 구분이 서명 쪽의 CNG 및 CAPI 처리 전반에도 흐릅니다. 이는 인증서 저장소 서명과 바이트 순서에 관한 글에서 다룹니다
어느 인증서가 이 서명을 했는가: SignerIdentifier는 서로 다른 두 가지다
RFC 5652 §5.3은 SignerIdentifier를 CHOICE로 정의하며, 한쪽 분기만 처리하는 검증기는 잘못된 키에 대해 조용히 검증해 버립니다. 첫 번째 분기는 issuerAndSerialNumber로, 발급자 Name을 원시 DER로, 그리고 일련번호 INTEGER를 담은 SEQUENCE이며, 이를 매칭하는 것은 CMS certificates 집합의 각 인증서에 대한 바이트 비교입니다. 두 번째 분기는 [0] subjectKeyIdentifier로, 암묵적으로 태그된 OCTET STRING이며, 이를 매칭하려면 헤더 필드를 비교하는 대신 인증서 내부를 파고들어야 합니다
그 파고드는 과정에는 사람들을 놀라게 하는 계층이 하나 있습니다. 키 식별자는 X.509v3 확장 안에 있으므로, HotPDF는 tbsCertificate의 [3] 확장 필드를 순회하여 OID가 2.5.29.14인 확장을 찾고, 선택적인 critical BOOLEAN을 건너뛴 다음, extnValue OCTET STRING을 가져옵니다. 그 옥텟 문자열은 식별자 자체가 아닙니다. RFC 5280 §4.2.1.2에 따르면 그 내용 자체가 DER이고, KeyIdentifier 타입은 또 하나의 OCTET STRING이므로, 실제 바이트에 도달하려면 두 번째로 파싱해야 합니다. 한 계층 일찍 멈추면 22바이트짜리 래퍼를 20바이트짜리 식별자와 비교하게 되어 어떤 인증서도 매칭되지 않고, 검증기는 다음으로 작성한 어떤 휴리스틱으로든 폴백하는데, 그것이 진짜 위험입니다. 집합의 첫 번째 인증서를 취하는 것은 유혹적인 지름길이며, CMS가 체인을 담고 있을 때는 언제나 틀립니다. 그리고 대부분의 경우가 그렇습니다. 리프 인증서가 반드시 먼저 오라는 법은 없기 때문입니다. HotPDF는 컨테이너가 정확히 하나의 인증서만 담고 있을 때만 매칭되지 않은 인증서를 받아들입니다. 여러 인증서가 존재할 때는 정확한 SignerIdentifier 매칭이 필수입니다. 중간 CA 공개 키로 다이제스트를 검증하는 것은 친절한 오류를 만들어내지 않습니다. 아무 문제 없는 문서에 대해 확신에 찬 무효를 만들어냅니다
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName은 P-256, P-384, P-521을 보고하므로, 감사 로그에는 단순히 ECDSA라는 단어 대신 실제로 어떤 곡선이 사용되었는지가 기록됩니다. 이 호출을 둘러싼 문서 수준의 배관, 특히 /ByteRange 세그먼트가 어떻게 해시되는지, 그리고 다이제스트가 왜 파싱된 객체 트리가 아니라 파일 자체에 대해 계산되어야 하는지는 PDF 서명 검증에 관한 자매 글에서 다룹니다
이 검증이 주지 않는 것
HPDFECDSAVerifyDigest의 초록색 결과는 단 하나의 질문에만 답합니다: 이 바이트들이 이 공개 키에 대응하는 개인 키로 서명되었는가. 그 키가 여러분이 신뢰해야 할 누군가의 것인지에 대해서는 아무것도 말하지 않습니다. 신뢰 앵커까지의 체인 구축, CRL이나 OCSP를 통한 폐기 확인, 정책 검사는 별개의 작업이며, 이것들 없이 유효한 서명이라고 보고하는 제품은 사용자가 가정하는 것보다 적은 것을 보고하고 있는 것입니다. 바로 이 이유 때문에 인증서 유효 기간은 THPDFSignatureInfo에 별도로 노출됩니다: 서명은 암호학적으로 검증되면서도 그 서명을 만든 인증서는 2년 전에 만료되었을 수 있습니다. 지원되는 곡선도 의도적으로 제한적입니다. 세 개의 NIST 소수 곡선만 처리되며, 다른 곡선에 대한 서명은 추측이 아니라 미지원을 반환합니다. CNG 경로는 Windows 전용이며, 이는 VCL 컴포넌트에는 올바른 절충이지만 이를 중심으로 크로스 플랫폼 서비스를 계획하기 전에 짚어둘 가치가 있습니다. 그리고 엄격함은 설정 가능하지 않습니다. 일부 레거시 서명자가 만들어냈다는 이유로 비최소 DER 길이를 받아들이는 관대 모드는 없습니다. 실제 운영에서 그런 파일을 만난다면, 정직한 대응은 그것을 기록하고 생성자를 추적하는 것이지, 그 파일이 통과할 때까지 파서를 넓히는 것이 아닙니다
여기서 설명한 ECDSA 검증 경로는 델파이와 C++Builder용 표준 HotPDF Component에, RSA PKCS#1 v1.5 및 RSA-PSS 경로와 전체 서명 정보 레코드와 함께 포함되어 제공됩니다. 제품 페이지에는 전자 서명에 관한 완전한 레퍼런스가 있습니다