Техническая статья

SM2, SM3 и SM4: подписание и шифрование PDF в Delphi

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, но сторонние валидаторы их не признают — веская причина переподписать всё, что покидает ваш собственный конвейер

Конструкция SignerInfo SM2-with-SM3 в PDFlibPas для Delphi: дайджест покрывает ZA = SM3 от ENTL, ID и параметров кривой с ID по умолчанию 1234567812345678, r и s следуют GB/T 32918.2 со свежей nonce из CSPRNG, а CMS несёт OID из GM/T 0006 — 1.2.156.10197.1.301, 1.2.156.10197.1.401 и 1.2.156.10197.1.501
Значение подписи — DER SEQUENCE из двух INTEGER над подписанными атрибутами; сборки до 3.589.1 писали незарегистрированную ветку OID, которую отвергают сторонние валидаторы, поэтому переподписывайте всё, что покидает ваш конвейер

Где ломалась проверка SM2: sign-байт DER и потерянный перенос

Большая часть инженерных усилий в коде SM2 ушла на два дефекта, выдававших правдоподобный вывод. Оба типичны для рукописной бигинтовой криптографии, и обоих дёшево избежать, зная о них

Первый — sign-байт DER. Значения ASN.1 INTEGER знаковые, поэтому энкодер правильно дописывает 0x00 впереди любого r или s со взведённым старшим битом, получая 33-байтное значение. Верификатор конвертировал каждое целое в фиксированную ширину 32 байта, не срезав этот байт. Примерно у половины всех значений r и s старший бит взведён, так что заметная доля свежеподписанных документов проваливала проверку. Первый пробный прогон вытянул короткие значения и прошёл; набор тестов Delphi Win32, подписывающий случайными nonce, вскрыл проблему немедленно. Теперь верификатор удаляет ведущий ноль у любого 33-байтного целого перед дополнением до 32 байт и отвергает всё длиннее

Дефект проверки подписи SM2 в PDFlibPas для Delphi и его исправление: кодирование ASN.1 INTEGER дописывает 0x00, когда старший бит r или s взведён, старый верификатор дополнял 33-байтное значение до фиксированных 32 байт, не срезав sign-байт, а исправленный верификатор удаляет ведущий ноль и отвергает всё длиннее
Примерно у половины значений r и s есть префикс 0x00, поэтому правдоподобный на вид вывод проваливал проверку массово; короткие пробные значения прошли первый прогон, пока набор Delphi Win32 не подписал случайными nonce

Второй — потерянный перенос в 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-шного

Структура криптофильтра SM4, записываемая PDFlibPas Encrypt со стойкостью 6 в Delphi: /V 4, /R 4 и /Length 128 с фильтром /StdCF в /CFM /SM4, пообъектный ключ MD5 над ключом файла, номерами объекта и поколения плюс sAlT, обрезанный до 16 байт, и каждый поток как 16-байтный IV с шифртекстом SM4-CBC и набивкой PKCS#7
Строки и потоки используют байтовый расклад AESV2 с ядром SM4, но /SM4 вне значений /CFM, которые определяет ISO 32000, поэтому массовые читалки не расшифруют файл, а для общего распространения стоит оставаться на AES-256

Пределы совместимости, о которых стоит знать до развёртывания

Честное резюме: подписание 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-целях