Подпись нескольких блоков данных

Работа с подписью составных данных в Web Crypto API требует учёта того, как именно данные представляются в памяти и каким образом криптографический алгоритм воспринимает входной поток. В отличие от прикладных протоколов, где сообщение уже структурировано, Web Crypto API оперирует бинарными буферами, и любая «разбивка на блоки» должна быть явно определена на уровне приложения.

Криптографическая подпись в Web Crypto API выполняется над непрерывной последовательностью байтов. Это означает, что концепция «нескольких блоков» не существует на уровне API — существует только один входной ArrayBuffer или TypedArray.

При необходимости подписать логически разделённые части данных используются три основных подхода:

  • конкатенация всех блоков в единый буфер
  • структурирование с явным кодированием длины (length-prefix encoding)
  • предварительное хеширование каждого блока с последующей агрегацией

Каждый из подходов влияет на безопасность и однозначность восстановления исходной структуры.

Базовая модель подписи в SubtleCrypto

Подпись создаётся через метод:

crypto.subtle.sign(algorithm, key, data)

где data — единый бинарный блок.

Пример подписи объединённых данных:

const enc = new TextEncoder();

const part1 = enc.encode("block-A:");
const part2 = enc.encode("block-B:");
const part3 = enc.encode("block-C:");

const totalLength =
  part1.byteLength + part2.byteLength + part3.byteLength;

const merged = new Uint8Array(totalLength);

merged.set(part1, 0);
merged.set(part2, part1.byteLength);
merged.set(part3, part1.byteLength + part2.byteLength);

const signature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  merged
);

В этом варианте криптографический алгоритм не знает о границах блоков — он видит только непрерывный поток байтов.

Проблема неоднозначности при склейке блоков

Прямое объединение данных без структуры приводит к риску коллизий представления.

Например:

  • блоки ["ab", "c"]
  • и блоки ["a", "bc"]

после конкатенации дают одинаковую строку abc, что делает невозможным восстановление исходной структуры.

Length-prefix encoding как базовый способ устранения коллизий

Для устранения неоднозначности каждый блок кодируется вместе с длиной:

function encodeBlock(block) {
  const length = block.byteLength;
  const buffer = new Uint8Array(4 + length);

  const view = new DataView(buffer.buffer);
  view.setUint32(0, length, false);

  buffer.set(block, 4);
  return buffer;
}

Сборка сообщения:

const enc = new TextEncoder();

const b1 = encodeBlock(enc.encode("ab"));
const b2 = encodeBlock(enc.encode("c"));

const total = new Uint8Array(b1.length + b2.length);
total.set(b1, 0);
total.set(b2, b1.length);

const signature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  total
);

Такой подход делает структуру однозначной: границы блоков восстановимы без двусмысленности.

Подпись нескольких независимых блоков через промежуточное хеширование

В случаях, когда данные поступают частями (например, из сетевого потока), применяется промежуточное хеширование через crypto.subtle.digest.

const enc = new TextEncoder();

const blocks = [
  enc.encode("header"),
  enc.encode("payload"),
  enc.encode("footer")
];

async function hashBlock(block) {
  return new Uint8Array(await crypto.subtle.digest("SHA-256", block));
}

const hashed1 = await hashBlock(blocks[0]);
const hashed2 = await hashBlock(blocks[1]);
const hashed3 = await hashBlock(blocks[2]);

const mergedHashes = new Uint8Array(
  hashed1.length + hashed2.length + hashed3.length
);

mergedHashes.set(hashed1, 0);
mergedHashes.set(hashed2, hashed1.length);
mergedHashes.set(hashed3, hashed1.length + hashed2.length);

const finalSignature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  mergedHashes
);

Этот подход отделяет смысловую обработку блоков от финальной подписи.

Использование HMAC для структурированных сообщений

HMAC часто применяется для подписания составных сообщений, так как он устойчив к линейным преобразованиям и хорошо работает с агрегированными структурами.

Пример формирования сообщения с разделителями:

const enc = new TextEncoder();

function joinWithSeparator(blocks, sep = "|") {
  const encodedSep = enc.encode(sep);

  let size = 0;
  const encoded = blocks.map(b => enc.encode(b));

  for (const b of encoded) {
    size += b.byteLength + encodedSep.byteLength;
  }

  const result = new Uint8Array(size - encodedSep.byteLength);

  let offset = 0;
  for (let i = 0; i < encoded.length; i++) {
    result.set(encoded[i], offset);
    offset += encoded[i].byteLength;

    if (i < encoded.length - 1) {
      result.set(encodedSep, offset);
      offset += encodedSep.byteLength;
    }
  }

  return result;
}

const data = joinWithSeparator(["A", "B", "C"]);

const signature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  data
);

RSA-PSS и ECDSA при подписании составных данных

При использовании асимметричных алгоритмов (RSA-PSS, ECDSA) принцип остаётся тем же: подписывается итоговый байтовый массив.

const enc = new TextEncoder();

const message = enc.encode("part1|part2|part3");

const signature = await crypto.subtle.sign(
  {
    name: "ECDSA",
    hash: "SHA-256"
  },
  privateKey,
  message
);

Ключевой момент заключается в том, что сама криптография не оперирует структурой — вся структура должна быть сериализована заранее.

Детерминированная сериализация как основа безопасности

Для корректной подписи нескольких блоков данных критично обеспечить:

  • фиксированный порядок блоков
  • однозначное кодирование границ
  • неизменность сериализации между платформами

На практике используется один из стандартных подходов:

  • JSON с нормализацией ключей
  • CBOR / MessagePack
  • length-prefix бинарный формат

Пример с JSON:

const message = JSON.stringify({
  a: "block-A",
  b: "block-B",
  c: "block-C"
});

const enc = new TextEncoder();
const data = enc.encode(message);

const signature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  data
);

Типичная ошибка при работе с несколькими блоками

Часто встречается попытка подписать каждый блок отдельно и затем объединить подписи:

// неправильный подход
sig1 = sign(block1)
sig2 = sign(block2)
final = concat(sig1, sig2)

Такой подход не создаёт криптографически связанной структуры и не обеспечивает целостности общего сообщения. Подпись должна охватывать всю структуру данных целиком или её однозначно сериализованное представление.

Сценарии потоковой обработки данных

Web Crypto API не предоставляет streaming sign API, поэтому при работе с потоками используется буферизация:

const chunks = [];

stream.ond ata = (chunk) => {
  chunks.push(chunk);
};

stream.on end = async () => {
  const totalLength = chunks.reduce((a, c) => a + c.length, 0);
  const merged = new Uint8Array(totalLength);

  let offset = 0;
  for (const c of chunks) {
    merged.set(c, offset);
    offset += c.length;
  }

  const signature = await crypto.subtle.sign(
    { name: "HMAC", hash: "SHA-256" },
    key,
    merged
  );
};

Такой подход сохраняет корректность криптографической операции, но требует памяти пропорционально размеру данных.

Разделение контекста данных (domain separation)

При подписи нескольких блоков часто требуется различать контексты:

  • разные типы сообщений
  • разные версии протокола
  • разные источники данных

Для этого добавляется префикс контекста:

const enc = new TextEncoder();

const context = enc.encode("v1:auth:");
const data = enc.encode("user=alice;action=login");

const buffer = new Uint8Array(context.length + data.length);
buffer.set(context, 0);
buffer.set(data, context.length);

const signature = await crypto.subtle.sign(
  { name: "HMAC", hash: "SHA-256" },
  key,
  buffer
);

Такое разделение предотвращает перенос подписи между различными доменами данных даже при совпадении полезной нагрузки.