PDFlibPas, библиотека PDF для Delphi и C++Builder, реализует китайский коммерческий криптографический набор на чистом Object Pascal: хеширование SM3 (GB/T 32905), блочное шифрование SM4 (GB/T 32907) и подписи на эллиптической кривой SM2 (GB/T 32918). Ими библиотека подписывает PDF подписями SM2-with-SM3 в CMS через SetSignProcessSM2Identity и шифрует документы криптофильтром SM4 через Encrypt со стойкостью 6, без провайдера ОС и без внешних DLL
Обычная причина хотеть это — требование закупки, а не любопытство. От систем, продаваемых в китайские госструктуры, финансы и здравоохранение, всё чаще ждут национальных алгоритмов, а Windows CryptoAPI и CNG не подписывают SM2 и не хешируют SM3. Если путь подписания идёт через платформу, ответ — просто нет. Чистая Pascal-реализация даёт одинаковый ответ на Win32, Win64 и целях FPC, и даёт его без движущейся нативной зависимости под документным конвейером
Зачем строить набор GB/T на чистом Object Pascal?
PDFlibPas строит SM2, SM3 и SM4 на Pascal, потому что ни один платформенный провайдер из уже поддерживаемых не умеет эту работу. У CryptoAPI и CNG нет поддержки подписей SM3, поэтому до появления SM2-идентичности библиотека нарочно отказывалась подписывать, когда дайджест был выставлен в SM3: альтернативой был SignerInfo, объявляющий один OID дайджеста и несущий подпись над чем-то другим, — а это молча неправильная подпись вместо видимой ошибки
У трёх алгоритмов есть и естественный порядок сборки, и библиотека ему последовала. Сперва пришёл SM3, потому что SM2 нужен он для identity-хеша ZA и для дайджеста сообщения. Вторым пришёл SM4 как независимый модуль. Последним пришёл SM2 — сперва как сырые примитивы, сверенные с опубликованной примерной кривой из приложения спецификации, затем как подписывающая идентичность, подключённая к тому же билдеру CMS, который уже обслуживает Ed25519, Ed448 и ML-DSA. Тот же подход описан для кривых Ed448 и Brainpool ECDSA на чистом Pascal и постквантового подписанта FIPS 204 ML-DSA, а SM2 встраивается в эту раму как ещё один тип ключа
Примитивы — публичные функции, так что их можно использовать и вне подписания PDF. SM3String и SM3ByteArray в PDFlibDigest возвращают сырой 32-байтный дайджест (байт на символ, не hex), а PDFlibSM4 экспортирует хелперы ECB и CBC в двух вкусах: выровненные по блоку версии, возвращающие пустую строку при неверной длине, и версии PKCS#7 вроде SM4EncryptCBCPKCS7 для данных произвольной длины
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-байтными, и поскольку выровненные по блоку хелперы по дизайну возвращают пустую строку при неверной длине, сбой проявился сбивающим с толку assert-ом, а не исключением
Подписание PDF с SM2-идентичностью
Процесс подписания PDFlibPas использует ключ SM2, как только SetSignProcessSM2Identity или SetSignProcessSM2IdentityFromFile прикрепит к нему сертификат и приватный ключ. Сертификат — DER, а приватный ключ может быть сырым 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 в GetSignProcessResult, а не в вызове идентичности
Код 8 в SetSignProcessDigestAlgorithm выбирает SM3, и с SM2-идентичностью он фактически обязателен: билдер CMS принуждает SM3 для ключа SM2, что бы ни просил процесс, — так же, как ключ Ed25519 принуждается к SHA-512. Обратная комбинация тоже заблокирована. С adbe.pkcs7.detached или ETSI.CAdES.detached процесс подписания, выставленный в SM3, но не держащий SM2-идентичности, завершается без подписи, потому что ни один другой подписант библиотеки не может выдать SM3-подпись, совпадающую с объявленным OID
Что входит в SignerInfo SM2-with-SM3?
Подпись SM2 в PDFlibPas вычисляется над DER-кодированными подписанными атрибутами — стандартная конструкция CMS, — но SM2 добавляет шаг, которого у ECDSA нет. GB/T 32918.2 хеширует сообщение вместе с ZA, дайджестом идентичности, определённым как ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA), где каждая часть кривой — 32-байтное значение big-endian, а ID — отличительный идентификатор подписанта. Затем подпись равна r = (e + x1) mod n и s = ((1 + d)-1(k - r·d)) mod n, со свежей разовой nonce k из CSPRNG библиотеки и повтором на вырожденных раундах
PDFlibPas использует отличительный идентификатор по умолчанию из спецификации, 16-символьную строку 1234567812345678, и на стороне подписания, и на стороне проверки. API для его смены нет. Это совпадает с подавляющим большинством развёрнутых сертификатов SM2, но если контрагент подписывает с кастомным ID, значения ZA разойдутся и подпись не провалидируется, хотя все остальные байты корректны
Пара (r, s) попадает в CMS как DER SEQUENCE из двух значений INTEGER — раскладка, которую использует любой SignerInfo семейства DSA. PDFlibPas пишет идентификаторы алгоритмов, зарегистрированные в GM/T 0006: 1.2.156.10197.1.301 для ключа SM2, 1.2.156.10197.1.401 для SM3 и 1.2.156.10197.1.501 для SM2-with-SM3. Запросы метки времени RFC 3161, созданные с SM3 (дайджест 8 в SetTimeStampOptions), несут тот же OID SM3. Выпуски до 3.589.1 писали незарегистрированную ветку 1.2.156.101.1; проверка её по-прежнему принимает, так что документы, подписанные теми сборками, продолжают валидироваться в PDFlibPas, но сторонние валидаторы их не признают — веская причина переподписать всё, что покидает ваш собственный конвейер
Где ломалась проверка SM2: sign-байт DER и потерянный перенос
Большая часть инженерных усилий в коде SM2 ушла на два дефекта, выдававших правдоподобный вывод. Оба типичны для рукописной бигинтовой криптографии, и обоих дёшево избежать, зная о них
Первый — sign-байт DER. Значения ASN.1 INTEGER знаковые, поэтому энкодер правильно дописывает 0x00 впереди любого r или s со взведённым старшим битом, получая 33-байтное значение. Верификатор конвертировал каждое целое в фиксированную ширину 32 байта, не срезав этот байт. Примерно у половины всех значений r и s старший бит взведён, так что заметная доля свежеподписанных документов проваливала проверку. Первый пробный прогон вытянул короткие значения и прошёл; набор тестов Delphi Win32, подписывающий случайными nonce, вскрыл проблему немедленно. Теперь верификатор удаляет ведущий ноль у любого 33-байтного целого перед дополнением до 32 байт и отвергает всё длиннее
Второй — потерянный перенос в 256-битном сложении. Элементы поля — восемь лимбов UInt32 с little-endian, а модульное сложение было записано как «сложи, затем вычти p, если результат не ниже p». Для приведённых входов сумма ниже 2p, но может превышать 2256, и когда так случалось, потерянный перенос сворачивал значение обратно в младшие 256 бит, где сравнение с p говорило, что всё в порядке. Удвоение точки с большой координатой шло вкривь; малые кратные — нет. Видимый симптом: 1G–8G совпадали с эталоном, а 9G, 10G и 11G — нет, и фикс нашли, сравнив 1280 пар входов и выходов сложения с независимо вычисленными значениями, спустившись в первую разошедшуюся пару
Исправленное сложение в PDFlibSM2 сохраняет перенос, возвращённый сырым сложением лимбов, и вычитает p один раз, когда перенос взведён или результат не ниже p; одного вычитания хватает, потому что p больше 2255. Паттерн применим к любому простому полю чуть выше степени двойки. Если ваш хелпер сложения выбрасывает финальный перенос, тестируйте кратные заметно дальше точки, где удвоение впервые пересекает границу слова, а не только 1G, 2G и 3G
Почему SM3 дважды ошибался, прежде чем стать правильным
Первая реализация SM3 проваливала все стандартные тестовые векторы, и независимая референсная реализация, написанная рядом, — тоже. Две реализации, согласные друг с другом и несогласные со стандартом, указывают на общее непонимание спецификации, а не на ошибку переписывания. Построчное сравнение формул с опубликованным текстом SM3 нашло три места, где код был написан по памяти
- Расширение сообщения применяет ротацию на 7 бит к W[j-13]; W[j-6] — обычный XOR-член
- SS1 комбинирует rotl(A, 12), E и повёрнутую раундовую константу сложением по модулю 232, а не XOR
- Для раундов с 16 по 63 FF — функция большинства (X AND Y) OR (X AND Z) OR (Y AND Z); выборочная функция в духе SHA принадлежит GG
После этих трёх правок пустая строка, abc и 64-байтный двухблочный вектор совпали. SM4 затем вошёл с первого раза, потому что S-box и тестовые векторы были взяты из текста спецификации до написания хоть строчки кода: раундовый ключ rk0 = F12186F9, одноблочный вектор 681EDF34D206965E86B3E94F536E4246 и вектор с миллионом итераций 595298C7C6FD271F0402F804C33D3F66. Работа над SM2 добавила родственное правило: длинные hex-константы генерируются группировкой и проверяются по длине, никогда не вбиваются руками, — после того как hand-скопированная координата базовой точки без одной цифры отправила отладочную сессию охотиться за багами реализации, которых не существовало
Шифрование PDF криптофильтром SM4
TPDFlib.Encrypt со стойкостью 6 шифрует выбранный документ SM4-128 через каркас криптофильтров ISO 32000-1 §7.6.5. Словарь шифрования использует стандартный security handler с /V 4, /R 4 и /Length 128, а криптофильтр /StdCF несёт /CFM /SM4. Сохранение со стойкостью 6 поднимает файл минимум до PDF 1.6, GetEncryptionLevel сообщает для такого файла 7, а шифрование с публичным ключом получает ту же опцию через EncryptPublicKey со стойкостью 4
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, за которым идёт шифртекст SM4-CBC с набивкой PKCS#7, — тот же расклад, что у AESV2. Пообъектный ключ следует Алгоритму 1 из ISO 32000-1 §7.6.2: ключ файла, три младших байта номера объекта и два байта номера поколения, плюс суффикс sAlT, всё хешируется MD5 и обрезается до 16 байт. Выведение ключа из пароля — ревизии 4, так что стойкость 6 наследует её пределы: пароли не проходят SASLprep (только AES-256 со стойкостями 3, 4 и 5 проходят), и выведение ключа не сильнее AESV2-шного
Пределы совместимости, о которых стоит знать до развёртывания
Честное резюме: подписание SM2 совместимо в границах OID и ID по умолчанию, описанных выше, а шифрование SM4 совместимо только с ПО, знающим ту же конвенцию. /SM4 не входит в значения /CFM, определённые в Table 25 ISO 32000-1 или в ISO 32000-2, — там определены None, V2, AESV2 и AESV3. Массовый PDF-ридер, встретивший /CFM /SM4, файл не расшифрует. Сам PDFlibPas читает /SM4 и вариант /SM4V1 через свой security handler, так что закрытый конвейер, где пишет и читает PDFlibPas, работает; файлу для широкой публики лучше AES-256
Для подписей граница — расклад сертификата и валидатор на той стороне. PLValidateSM2Identity, проверка за процессом подписания, принимает расклад GM/T 0015, где SubjectPublicKeyInfo несёт id-ecPublicKey с кривой SM2 1.2.156.10197.1.301 в параметре, а также сертификаты, кладущие OID SM2 прямо в поле алгоритма, и отвергает всё, чья выведенная публичная точка не совпала с приватным скаляром. Прежде чем полагаться на подписи 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-переменную на входе, вход пустел до того, как подпрограмма его прочла, и проверка идентичности проваливалась на совершенно исправном ключе. Любому API, берущему ключ как Const и возвращающему выведенный ключ как Out, нужны отдельные переменные на стороне вызывающего
FAQ
Нужны ли PDFlibPas Windows CNG или внешняя DLL для SM2, SM3 и SM4?
Нет. PDFlibPas реализует все три алгоритма на Object Pascal в PDFlibDigest, PDFlibSM4 и PDFlibSM2, а подписи SM2 строятся и проверяются CMS-кодом библиотеки. Windows CryptoAPI и CNG в производстве подписи SM2 не участвуют
Можно ли использовать SM2 с SHA-256 вместо SM3?
Нет. Когда процесс подписания держит SM2-идентичность, билдер CMS PDFlibPas всегда использует SM3, потому что GB/T 32918.2 определяет подписи SM2 над дайджестом SM3, включающим identity-хеш ZA. Выставка дайджеста в SHA-256 на SM2-процессе результат не меняет
Откроет ли Adobe Acrobat PDF, зашифрованный SM4 силами PDFlibPas?
Не рассчитывайте на это. Криптофильтр /CFM /SM4 вне значений, определённых ISO 32000, поэтому массовые ридеры его не признают. Используйте шифрование SM4 для закрытых систем, читающих файлы PDFlibPas-ом или совместимой реализацией, и AES-256 для документов, предназначенных к общему распространению
Подписание SM2, хеширование SM3, шифрование SM4 и весь набор API подписания PAdES поставляются с PDFlibPas, Delphi PDF Library, которая компилируется с Delphi, C++Builder и FPC/Lazarus на Windows и Unix-целях