PDFlibPas реализует SHA-3 и SHAKE256 на одной перестановке Keccak-f[1600] на Object Pascal. Два семейства различаются ровно в двух местах: доменный байт pad10*1, $06 для SHA-3 и $1F для SHAKE согласно FIPS 202 §6.2 и §6.3, и правило rate. SHAKE256 фиксирует свой rate на 136 байтах независимо от длины вывода
Второе различие — то, на котором ошибаются, потому что оно вообще не выглядит различием. SHA3-256 тоже работает на rate 136, так что SHAKE256, построенный копированием кодового пути SHA-3, проходит каждый 32-байтовый тест, который вы в него бросаете, а потом выдаёт мусор при первой просьбе на 64. Дальше — как два семейства сошлись на одной записи контекста в PDFlibDigest, где останавливается одношаговый вход, и почему потоковый XOF должен был появиться до того, как смогли появиться Ed448 или ML-DSA
Почему SHA-3 и SHAKE256 делят один контекст Keccak?
Потому что единственное состояние на семейство — это один байт, и PDFlibPas хранит его в TSHA3Context как Suffix. Как только разделитель доменов живёт в контексте, а не захардкожен в финализирующей процедуре, SHA3AbsorbBlock, SHA3Update и SHA3Final обслуживают оба семейства без изменений, и в библиотеке ровно одна копия 24-раундовой перестановки. Инициализаторы — вот где пути расходятся, и расходятся полностью
Type
TSHA3Context= Record
State: Array [0.. 24] Of UInt64;
Buffer: Array [0.. 135] Of Byte;
BufferLength: Integer;
Rate: Integer;
OutputLength: Integer;
// Разделитель доменов: $06 для SHA3, $1F для SHAKE (FIPS 202 §6.2/6.3)
Suffix: Byte;
End;
Procedure SHA3Init(Var Context: TSHA3Context; OutputBits: Integer);
Begin
FillChar(Context, SizeOf(Context), 0);
Context.OutputLength:= OutputBits div 8;
Context.Rate:= 200- 2* Context.OutputLength; // rate следует за размером дайджеста
Context.Suffix:= $06;
End;
// SHAKE256: rate зафиксирован на 136 байтах, что бы ни просил вызывающий,
// потому что SHAKE — функция с расширяемым выводом
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
FillChar(Context, SizeOf(Context), 0);
Context.OutputLength:= OutputBytes;
Context.Rate:= 136;
Context.Suffix:= $1F;
End;
Формула в SHA3Init даёт 136, 104 и 72 байта rate для SHA3-256, SHA3-384 и SHA3-512, так что rate и уровень безопасности спаяны вместе для семейства фиксированной длины. SHAKE256Init намеренно разрывает эту связь: OutputLength становится запросом на байты, а не декларацией криптостойкости, и ёмкость губки остаётся 512 бит независимо от того, сколько вывода вы тянете. Эта развязка и есть определение функции с расширяемым выводом, и запись её как двух инициализаторов над одним контекстом делает различие видимым в исходнике, а не погребённым в комментарии
Потолок 136 байт у одношаговой точки входа
Удобные функции в PDFlibPas отказывают всему вне диапазона 1..136 байт, и этот лимит честный, а не произвольный. SHAKE256StreamRange и SHAKE256ByteArray обе идут через SHA3Final, который дополняет последний блок, один раз переставляет и читает вывод прямо из состояния. Только первые Rate байт этого состояния — легальный вывод, так что одно сжатие даёт максимум 136 байт; дальше нужна свежая перестановка на каждый блок. Оба реальных потребителя в окно всё равно влезают: профиль подписи PDF дайджестирует 32 байта, а Ed448 нужно 114 по RFC 8032 §5.2. SHAKE256StreamRange принимает ту же тройку FirstLength, SecondStart, SecondLength, что и SHA256StreamRange и SHA3_256StreamRange, так что код валидации обходит подпись /ByteRange одинаково для каждого алгоритма и никогда не делает исключений для XOF
Что ломается в pad10*1, когда позиция попадает на rate-1?
Ничего, если вы XORите оба пад-байта в состояние, а не присваиваете их. FIPS 202 §B.2 кладёт доменный суффикс на текущую позицию поглощения, а $80 — на последний байт блока. Когда сообщению случается оставить позицию на Rate- 1, эти две записи целятся в один байт и обязаны скомбинироваться в $9F. Присваивайте вместо XOR — и один из них победит, дополнение молча неверно ровно для одной длины сообщения из 136, и все ваши регрессионные векторы проходят, потому что ни один не попадает туда. PLShakeXOFFinalize в PDFlibPas XORит оба, так что пересекающийся случай выпадает из общего без ветвления
// Дополняет последний частичный блок и входит в фазу squeeze
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
If Not Ctx.Absorbing Then
Exit;
Ctx.Absorbing:= False;
// суффикс на SqueezePos, $80 на Rate-1; когда SqueezePos = Rate-1, оба
// XOR попадают в один байт и корректно комбинируются в $9F
Ctx.State[Ctx.SqueezePos div 8]:= Ctx.State[Ctx.SqueezePos div 8]xor
(UInt64(Ctx.Suffix)shl (8* (Ctx.SqueezePos and 7)));
Ctx.State[(Ctx.Rate- 1)div 8]:= Ctx.State[(Ctx.Rate- 1)div 8]xor
(UInt64($80)shl (8* ((Ctx.Rate- 1)and 7)));
SHA3Permute(Ctx.State);
Ctx.SqueezePos:= 0;
End;
TPLShakeXOF: поглощение прямо в губку
PDFlibPas выставляет неограниченный вывод через отдельную запись, TPLShakeXOF, держащую только State как 25 слов UInt64, Rate, Suffix, флаг Absorbing и SqueezePos. Буфера сообщения нет: PLShakeXOFAbsorb XORит входящие байты прямо в состояние и переставляет, когда заполняет блок, а SqueezePos служит смещением в обеих фазах. PLShakeXOFInit выбирает вариант одним булевым, True для SHAKE128 на rate 168 и False для SHAKE256 на rate 136, а PLShakeXOFSqueeze переставляет до тех пор, пока вызывающий не получит запрошенное число байт. Вывод через границу блока проверялся на 200 байтах, за пределами первой границы блока в обоих вариантах
Var
Ctx: TPLShakeXOF;
Digest: Array [0.. 113] Of Byte; // Ed448 нужно 114 байт, RFC 8032 §5.2
Begin
PLShakeXOFInit(Ctx, False); // False = SHAKE256, rate 136
PLShakeXOFAbsorb(Ctx, @Dom4[1], Length(Dom4));
PLShakeXOFAbsorb(Ctx, @Prefix[1], Length(Prefix));
PLShakeXOFAbsorb(Ctx, @Msg[1], Length(Msg));
PLShakeXOFFinalize(Ctx);
PLShakeXOFSqueeze(Ctx, @Digest[0], SizeOf(Digest));
End;
// Или, когда нужен только один буфер на вход и один на выход:
// PLShakeXOF(False, Input, Output, 114)
Поглощение несколькими вызовами важнее, чем выглядит. Ed448 предваряет сообщение строкой dom4 и 57-байтовым значением перед хешированием, а rejection-сэмплеру хочется скормить сид и байты индекса без предварительной конкатенации строк. То, что потоковая запись появилась раньше обоих потребителей, и было сутью: чисто паскалевской работе с Ed448 и кривой Brainpool нужно 114 байт за один присест, а порт ML-DSA по FIPS 204 сжимает бесконечно из обоих вариантов. Ни один из них нельзя было бы написать против одношагового API
Почему PDFlibPas отказывается выставлять SHAKE256 как селектор дайджеста?
Потому что легального способа его выбрать нет. TPDFlibDigestAlgorithm в PDFlibDigitalSign содержит daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512 — и всё. Нет daSHAKE256, и это пропуск не по недосмотру, а решение. RFC 8702 регистрирует SHAKE в CMS, но внутри подписи PDF единственное место, куда ISO/TS 32002 помещает SHAKE256, — это внутренний дайджест Ed448, где алгоритм подписи выбирает его сам, а вызывающий — нет. Опубликовать его как самостоятельное значение DigestAlgorithm значило бы позволить любому спарить SHAKE256 с RSA или ECDSA и произвести словарь подписи, который не примет ни один валидатор. Возможность поставляется, селектор — нет, и код на стороне проверки вызывает SHAKE256StreamRange по раскладке /ByteRange напрямую. Если вы выбираете дайджест для подписей, которые контрагент должен валидировать уже сегодня, ответ остаётся прежним: daSHA256 … daSHA3_512 внутри рабочего процесса PAdES B-B до B-LTA с таймстампингом и данными долгосрочной валидации; а когда SHA-3 вообще станет рутиной в PDF, решает ISO/TS 32001
Тестирование XOF: вектор, который выглядит проходящим
Сравнивайте полную длину вывода и используйте больше одного вектора. Результат SHAKE256 для пустой строки — тот, что все запоминают, и это слабый тест именно потому, что его так широко цитируют. 32-байтовый вывод SHAKE256 для abc ещё хуже: его первые 16 байт, 483366601360a8771c6863080cc4114d, совпадают с несколькими соседними векторами, так что усечённое сравнение сообщает об успехе, пока различающая половина, 8db44530f8f1e1ee4f94ea37e78b5739, остаётся непроверенной. Регрессия PDFlibPas сравнивает оба вектора по всей длине. Добавьте и случай через границу блока, потому что цикл сжатия, ни разу не пересекший границу rate, не тестировался вообще
Более широкий урок из складывания SHAKE в существующую реализацию SHA-3 в том, что общий код никогда не был риском. Keccak-f[1600] — одна и та же перестановка для обоих семейств, и она либо совпадает с векторами, либо нет. Риск живёт в двух байтах и одном целом, которые различаются, и в потолке, который надо честно заявить, а не давать вызывающим обнаружить. Чтобы увидеть, как слой дайджестов ложится рядом с инструментарием подписи, шифрования и PDF/A в нативной кодовой базе на Object Pascal, страница продукта PDFlibPas Delphi PDF library перечисляет полный набор компонентов и поддерживаемую матрицу компиляторов