Delphi와 C++Builder용 PDF 라이브러리인 PDFlibPas는 중국 상용 암호 스위트를 순수 Object Pascal로 구현합니다. SM3 해싱(GB/T 32905), SM4 블록 암호화(GB/T 32907), SM2 타원 곡선 서명(GB/T 32918)입니다. 이를 바탕으로 SetSignProcessSM2Identity를 통해 SM2-with-SM3 CMS 서명으로 PDF에 서명하고, strength 6의 Encrypt로 SM4 crypt filter로 문서를 암호화합니다. OS 프로바이더도 외부 DLL도 끼지 않습니다
이걸 원하는 이유는 대개 호기심이 아니라 조달 요건입니다. 중국 정부, 금융, 의료 시장에 납품되는 시스템은 국가 알고리즘 사용을 점점 더 요구받는데, Windows CryptoAPI와 CNG는 SM2 서명이나 SM3 해싱을 하지 못합니다. 서명 경로가 플랫폼을 거친다면 답은 단순히 불가입니다. 순수 Pascal 구현은 Win32, Win64, FPC 타깃에서 같은 답을 주고, 문서 파이프라인 아래에서 네이티브 의존성이 흔들리는 일도 없습니다
GB/T 스위트를 순수 Object Pascal로 만든 이유
PDFlibPas가 SM2, SM3, SM4를 Pascal로 만든 이유는 이미 지원하는 어느 플랫폼 프로바이더도 이 일을 못 하기 때문입니다. CryptoAPI와 CNG에는 SM3 서명 지원이 없으므로, SM2 아이덴티티가 존재하기 전에는 라이브러리가 다이제스트가 SM3로 설정되면 의도적으로 서명을 거부했습니다. 대안은 하나의 다이제스트 OID를 선언해 놓고 다른 무언가에 대한 서명을 실은 SignerInfo인데, 이는 눈에 보이는 오류가 아니라 조용한 잘못된 서명입니다
세 알고리즘에는 자연스러운 구현 순서도 있었고 라이브러리는 그 순서를 따랐습니다. SM2가 ZA 아이덴티티 해시와 메시지 다이제스트에 SM3를 필요하므로 SM3가 먼저 왔습니다. SM4는 독립 유닛으로 두 번째였습니다. SM2는 마지막에 왔는데, 먼저 사양 부록의 공개 예시 곡선으로 검증된 raw 프리미티브로, 그다음 이미 Ed25519, Ed448, ML-DSA를 담당하던 같은 CMS 빌더에 연결된 서명 아이덴티티로 자리 잡았습니다. 같은 접근은 순수 Pascal의 Ed448 및 Brainpool ECDSA 곡선과 FIPS 204 ML-DSA 포스트 퀀텀 서명자에서 설명하며, SM2는 키 타입 하나를 더하는 형태로 그 프레임워크에 끼어듭니다
프리미티브는 공개 함수라서 PDF 서명 밖에서도 쓸 수 있습니다. PDFlibDigest의 SM3String과 SM3ByteArray는 raw 32바이트 다이제스트를 반환하고(16진수가 아니라 문자당 1바이트), PDFlibSM4는 두 가지 맛의 ECB와 CBC 헬퍼를 내보냅니다. 잘못된 길이에 빈 문자열을 반환하는 블록 정렬 버전과, 임의 길이 데이터를 위한 SM4EncryptCBCPKCS7 같은 PKCS#7 버전입니다
uses
SysUtils, PDFlibDigest, PDFlibSM4;
function RawToHex(const S: AnsiString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + IntToHex(Byte(S[I]), 2);
end;
var
Digest, Key, IV, Cipher, Plain: AnsiString;
begin
// GB/T 32905 샘플: SM3("abc")
Digest := SM3String('abc');
Assert(LowerCase(RawToHex(Digest)) =
'66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');
// SM4에는 16바이트 키가 필요하고 CBC에는 16바이트 IV가 필요합니다
Key := 'SixteenByteKey!!';
IV := 'InitVector16byte';
Cipher := SM4EncryptCBCPKCS7(Key, IV, 'Invoice 2026-0042 approved');
Plain := SM4DecryptCBCPKCS7(Key, IV, Cipher);
Assert(Plain = 'Invoice 2026-0042 approved');
end;
그 16바이트 문자열을 손으로 세다가 다치는 건 본인 몫입니다. SM4 테스트 스위트의 16바이트일 터였던 테스트 문자열 둘이 실제로는 17바이트였고, 블록 정렬 헬퍼는 설계상 잘못된 길이에 빈 문자열을 반환하므로, 실패는 예외 대신 혼란스러운 assertion로 드러났습니다
SM2 아이덴티티로 PDF 서명하기
PDFlibPas 서명 프로세스는 SetSignProcessSM2Identity나 SetSignProcessSM2IdentityFromFile이 인증서와 개인 키를 붙이는 순간 SM2 키를 씁니다. 인증서는 DER이고, 개인 키는 raw 32바이트 스칼라, 암호화되지 않은 PKCS#8 컨테이너, SEC 1 ECPrivateKey 중 하나입니다. SM2 아이덴티티를 붙이면 같은 프로세스의 Ed25519, Ed448, ML-DSA, Brainpool 아이덴티티는 지워지고 인증서 저장소와 CNG 서명은 꺼지므로, 하나의 서명 프로세스가 경쟁하는 두 키를 갖는 일은 없습니다
var
Pdf: TPDFlib;
SignId: Integer;
begin
Pdf := TPDFlib.Create;
try
SignId := Pdf.NewSignProcessFromFile('contract.pdf', '');
if SignId = 0 then
raise Exception.Create('cannot open source PDF');
Pdf.SetSignProcessField(SignId, 'Signature1');
if Pdf.SetSignProcessSM2IdentityFromFile(SignId,
'signer-sm2.cer', 'signer-sm2.key') <> 1 then
raise Exception.Create('SM2 certificate or key file missing');
Pdf.SetSignProcessInfo(SignId, 'Approved', 'Shanghai', '[email protected]');
Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
Pdf.SetSignProcessDigestAlgorithm(SignId, 8); // 8 = SM3
Pdf.EndSignProcessToFile(SignId, 'contract-signed.pdf');
if Pdf.GetSignProcessResult(SignId) <> 1 then
raise Exception.CreateFmt('SM2 signing failed, code %d',
[Pdf.GetSignProcessResult(SignId)]);
Pdf.ReleaseSignProcess(SignId);
finally
Pdf.Free;
end;
end;
SetSignProcessSM2IdentityFromFile의 반환값이 말해 주는 것과 말하지 않는 것에 주목하십시오. 1의 반환은 두 파일을 모두 읽어 서명 프로세스에 저장했다는 뜻입니다. 진짜 검사, 즉 개인 스칼라에서 공개 키를 유도해 인증서와 비교하는 일은 서명이 적용될 때 실행되므로, 맞지 않는 키 쌍은 아이덴티티 호출이 아니라 1이 아닌 GetSignProcessResult로 드러납니다
SetSignProcessDigestAlgorithm의 코드 8이 SM3를 고르며, SM2 아이덴티티와 함께면 사실상 필수입니다. CMS 빌더는 프로세스가 뭘 요청했든 SM2 키에는 SM3를 강제하는데, Ed25519 키가 SHA-512로 강제되는 것과 같은 방식입니다. 반대 조합도 막혀 있습니다. adbe.pkcs7.detached나 ETSI.CAdES.detached에서 SM3로 설정된 서명 프로세스가 SM2 아이덴티티를 갖고 있지 않으면 서명 없이 종료합니다. 라이브러리의 다른 서명자는 선언된 OID와 일치하는 SM3 기반 서명을 만들 수 없기 때문입니다
SM2-with-SM3 SignerInfo에는 무엇이 들어갈까
PDFlibPas의 SM2 서명은 DER 인코딩된 서명 속성, 즉 표준 CMS 구조에 대해 계산되지만, SM2에는 ECDSA에 없는 단계가 하나 추가됩니다. GB/T 32918.2는 메시지를 ZA와 함께 해싱하는데, ZA는 ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA)로 정의된 아이덴티티 다이제스트이며, 곡선 부분은 전부 32바이트 빅엔디안 값이고 ID는 서명자를 식별하는 구분자입니다. 서명은 r = (e + x1) mod n, s = ((1 + d)-1(k - r·d)) mod n이며, k는 라이브러리 CSPRNG에서 새로 뽑고 퇴화한 라운드에는 재시도합니다
PDFlibPas는 서명과 검증 양쪽에서 사양의 기본 구분자, 16글자 문자열 1234567812345678을 사용합니다. 이를 바꾸는 API는 없습니다. 배포된 SM2 인증서의 압도적 다수와 일치하지만, 상대방이 커스텀 ID로 서명하면 ZA 값이 달라져 다른 모든 바이트가 옳아도 서명이 검증되지 않습니다
(r, s) 쌍은 DSA 계열 SignerInfo가 쓰는 배치인 두 INTEGER 값의 DER SEQUENCE로 CMS에 들어갑니다. PDFlibPas는 GM/T 0006에 등록된 알고리즘 식별자를 씁니다. SM2 키에는 1.2.156.10197.1.301, SM3에는 1.2.156.10197.1.401, SM2-with-SM3에는 1.2.156.10197.1.501입니다. SM3로 만든 RFC 3161 타임스탬프 요청(SetTimeStampOptions 다이제스트 8)도 같은 SM3 OID를 실립니다. 3.589.1 이전 릴리스는 등록되지 않은 1.2.156.101.1 아크를 썼습니다. 검증은 여전히 받아들이므로 그 빌드로 서명한 문서는 PDFlibPas에서 계속 검증되지만, 서드파티 검증기는 인식하지 못하므로 자기 파이프라인을 떠나는 것은 다시 서명할 좋은 이유입니다
SM2 검증이 깨졌던 지점: DER 부호 바이트와 사라진 캐리
SM2 코드의 엔지니어링 노력은 대부분 그럴듯한 출력을 만들어 내는 두 결함에 들어갔습니다. 둘 다 손으로 쓴 big-integer 암호학의 전형이고, 알고 나면 둘 다 피하기 쉽습니다
첫 번째는 DER 부호 바이트였습니다. ASN.1 INTEGER 값은 부호가 있어서, 인코더는 최상위 비트가 켜진 r이나 s에 0x00을 앞에 붙여 33바이트 값을 만듭니다. 검증기는 그 바이트를 먼저 떼지 않은 채 각 정수를 고정 32바이트 폭으로 변환했습니다. 모든 r과 s의 절반가량이 최상위 비트가 켜져 있으므로, 새로 서명한 문서의 상당수가 검증에 실패했습니다. 첫 프로브 실행은 짧은 값을 뽑아 통과했고, 무작위 nonce로 서명하는 Delphi Win32 스위트가 문제를 즉시 드러냈습니다. 검증기는 이제 33바이트 정수의 앞쪽 0을 32바이트로 패딩하기 전에 지우고, 더 긴 것은 거부합니다
두 번째는 256비트 덧셈에서 사라진 캐리였습니다. 필드 원소는 여덟 개의 리틀엔디안 UInt32 림이고, 모듈러 덧셈은 "더한 뒤 결과가 p보다 작지 않으면 p를 뺀다"로 쓰여 있었습니다. 기약된 입력의 합은 2p 미만이지만 여전히 2256을 넘을 수 있는데, 넘는 순간 버려진 캐리가 값을 하위 256비트로 접어 넣고, p와의 비교는 모두 멀쩡하다고 말했습니다. 큰 좌표의 점 배더가 잘못됐고 작은 배수는 멀쩡했습니다. 눈에 보이는 증상은 1G부터 8G까지는 레퍼런스와 일치하지만 9G, 10G, 11G는 아니라는 것이었고, 수정은 1280개의 덧셈 입력과 출력을 독립 계산 값과 비교해 처음 어긋난 쌍을 파고들어 찾았습니다
고쳐진 PDFlibSM2의 덧셈은 raw 림 덧셈이 돌려준 캐리를 보존하고, 캐리가 켜졌거나 결과가 p보다 작지 않을 때 p를 한 번 뺍니다. p가 2255를 넘으므로 한 번의 뺄셈으로 충분합니다. 이 패턴은 2의 거듭제곱 바로 위의 소수체 어디에나 적용됩니다. 덧셈 헬퍼가 마지막 캐리를 버린다면, 1G, 2G, 3G뿐 아니라 배더가 처음으로 워드 경계를 넘는 지점을 훨씬 지나서까지 배수를 테스트하십시오
SM3가 옳아지기 전에 두 번 틀렸던 이유
첫 SM3 구현은 모든 표준 테스트 벡터를 통과하지 못했고, 곁에 함께 쓴 독립 레퍼런스 구현도 마찬가지였습니다. 서로 일치하면서 표준과 어긋나는 두 구현은 전사 오류가 아니라 사양에 대한 공유된 오해를 가리킵니다. 공개된 SM3 문서와 수식을 한 줄씩 대조해 코드가 기억으로 쓰였던 세 곳을 찾았습니다
- 메시지 확장은 7비트 회전을 W[j-13]에 적용합니다. W[j-6]는 단순 XOR 항입니다
- SS1은 rotl(A, 12)과 E, 회전한 라운드 상수를 XOR이 아니라 mod 232 덧셈으로 결합합니다
- 라운드 16에서 63까지 FF는 다수 함수 (X AND Y) OR (X AND Z) OR (Y AND Z)입니다. SHA 스타일의 선택 함수는 GG에 속합니다
그 세 가지를 바로잡은 뒤에는 빈 문자열, abc, 64바이트 2블록 벡터가 모두 일치했습니다. SM4는 첫 시도에 들어갔습니다. 코드를 쓰기 전에 S-box와 테스트 벡터를 사양 문서에서 가져왔기 때문입니다. 라운드 키 rk0 = F12186F9, 단일 블록 벡터 681EDF34D206965E86B3E94F536E4246, 백만 회 반복 벡터 595298C7C6FD271F0402F804C33D3F66. SM2 작업은 관련 규칙을 하나 더했습니다. 긴 16진수 상수는 손으로 타이핑하지 않고 그룹핑해서 생성하고 길이 검사를 합니다. 손으로 베낀 기준점 좌표 하나가 한 자리를 잃어버려, 존재하지 않는 구현 버그를 좇는 디버깅 세션을 보냈던 일이 계기였습니다
SM4 crypt filter로 PDF 암호화하기
strength 6의 TPDFlib.Encrypt는 ISO 32000-1 §7.6.5의 crypt filter 프레임워크를 통해 선택된 문서를 SM4-128로 암호화합니다. 암호화 딕셔너리는 표준 시큐리티 핸들러에 /V 4, /R 4, /Length 128을 쓰고, /StdCF crypt filter는 /CFM /SM4를 담습니다. strength 6으로 저장하면 파일이 최소 PDF 1.6으로 올라가고, GetEncryptionLevel은 그런 파일에 7을 보고합니다. 공개 키 암호화는 strength 4의 EncryptPublicKey로 같은 선택지를 얻습니다
var
Pdf: TPDFlib;
Perm: Integer;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report.pdf', '') <> 1 then
raise Exception.Create('cannot load PDF');
// 인쇄, 복사, 양식 작성 허용, 편집과 조립은 불가
Perm := Pdf.EncodePermissions(1, 1, 0, 0, 1, 1, 0, 1);
if Pdf.Encrypt('owner-secret', 'reader-pass', 6, Perm) <> 1 then
raise Exception.Create('SM4 encryption failed');
Pdf.SaveToFile('report-sm4.pdf');
finally
Pdf.Free;
end;
end;
각 문자열과 스트림은 16바이트 무작위 IV 뒤에 PKCS#7 패딩의 SM4-CBC 암호문으로 암호화되며, AESV2가 쓰는 것과 같은 배치입니다. 객체별 키는 ISO 32000-1 §7.6.2의 알고리즘 1을 따릅니다. 파일 키, 객체 번호의 낮은 3바이트, 세대 번호의 2바이트, sAlT 접미어를 MD5로 해싱해 16바이트로 자릅니다. 비밀번호-키 유도는 revision 4 방식이므로 strength 6은 그 한계를 물려받습니다. 비밀번호는 SASLprep 처리되지 않고(AES-256 strength 3, 4, 5만 해당), 키 유도는 AESV2보다 강하지 않습니다
배포 전에 알아야 할 상호 운용성 한계
솔직한 요약은 이렇습니다. SM2 서명은 위에서 설명한 OID와 기본 ID의 경계 안에서 상호 운용되지만, SM4 암호화는 같은 관례를 아는 소프트웨어와만 통합니다. /SM4는 ISO 32000-1 표 25나 ISO 32000-2가 정의한 /CFM 값이 아닙니다. 둘은 None, V2, AESV2, AESV3를 정의합니다. /CFM /SM4를 만난 주류 PDF 리더는 파일을 복호화하지 못합니다. PDFlibPas 자체는 자기 시큐리티 핸들러로 /SM4와 변형인 /SM4V1을 읽으므로, PDFlibPas가 쓰고 읽는 폐쇄 파이프라인은 동작합니다. 일반 대중용 파일은 대신 AES-256을 써야 합니다
서명의 경계는 인증서 레이아웃과 상대편 검증기입니다. 서명 프로세스 뒤의 검사인 PLValidateSM2Identity는 GM/T 0015 레이아웃, 즉 SubjectPublicKeyInfo가 파라미터로 SM2 곡선 1.2.156.10197.1.301을 단 id-ecPublicKey를 실는 형태를 받아들이고, SM2 OID를 알고리즘 필드 자체에 넣는 인증서도 받아들이며, 유도된 공개 점이 개인 스칼라와 맞지 않으면 무엇이든 거부합니다. 프로덕션에서 SM2 서명에 의존하기 전에, 실제로 쓸 CA의 인증서로 문서에 서명하고 상대방이 돌리는 검증기로 검증해 보십시오. 서명 프로세스를 시작하기 전에 아이덴티티 검사를 직접 돌릴 수도 있습니다:
uses
SysUtils, PDFlibModernSign;
var
CertDER, KeyDER, Scalar: AnsiString;
begin
// CertDER와 KeyDER는 디스크나 HSM 내보내기에서 적재
// Out 매개변수는 별도 변수에 담을 것: KeyDER를 Const 입력과 Out 출력
// 양쪽으로 동시에 넘기면 입력이 먼저 지워집니다
if not PLValidateSM2Identity(CertDER, KeyDER, Scalar) then
raise Exception.Create('certificate and SM2 private key do not match');
FillChar(Pointer(Scalar)^, Length(Scalar), 0);
end;
그 주석은 통합 작업의 실제 결함을 기록한 것입니다. 같은 AnsiString 변수가 Const 입력과 Out 출력으로 동시에 넘어가면 컴파일러가 진입 시 Out 변수를 파이널라이즈하고, 입력은 루틴이 읽기 전에 비게 되며, 아이덴티티 검사는 완벽히 정상인 키에서 실패합니다. 키를 Const로 받고 유도된 키를 Out으로 돌려주는 API라면 호출자 쪽에서 변수를 나눠야 합니다
FAQ
PDFlibPas는 SM2, SM3, SM4에 Windows CNG나 외부 DLL이 필요한가요?
아니요. PDFlibPas는 세 알고리즘을 모두 Object Pascal로 PDFlibDigest, PDFlibSM4, PDFlibSM2에 구현하고, SM2 서명은 라이브러리 CMS 코드가 만들고 검증합니다. SM2 서명을 만드는 데 Windows CryptoAPI와 CNG는 관여하지 않습니다
SM3 대신 SHA-256으로 SM2를 쓸 수 있나요?
아니요. 서명 프로세스가 SM2 아이덴티티를 갖고 있으면 PDFlibPas CMS 빌더는 항상 SM3를 씁니다. GB/T 32918.2가 ZA 아이덴티티 해시를 포함한 SM3 다이제스트에 대한 SM2 서명을 정의하기 때문입니다. SM2 서명 프로세스에 다이제스트를 SHA-256으로 설정해도 결과는 바뀌지 않습니다
Adobe Acrobat은 PDFlibPas가 SM4로 암호화한 PDF를 열 수 있나요?
기대하지 마십시오. /CFM /SM4 crypt filter는 ISO 32000이 정의한 값 밖이라 주류 리더가 인식하지 못합니다. SM4 암호화는 PDFlibPas나 호환 구현으로 파일을 읽는 폐쇄 시스템에, AES-256은 일반 배포용 문서에 쓰십시오
SM2 서명, SM3 해싱, SM4 암호화, PAdES 서명 API 전체는 Delphi PDF Library인 PDFlibPas와 함께 제공되며, Windows와 Unix 타깃에서 Delphi, C++Builder, FPC/Lazarus로 컴파일됩니다