Работа с подписью составных данных в Web Crypto API требует учёта того, как именно данные представляются в памяти и каким образом криптографический алгоритм воспринимает входной поток. В отличие от прикладных протоколов, где сообщение уже структурировано, Web Crypto API оперирует бинарными буферами, и любая «разбивка на блоки» должна быть явно определена на уровне приложения.
Криптографическая подпись в Web Crypto API выполняется над
непрерывной последовательностью байтов. Это означает, что концепция
«нескольких блоков» не существует на уровне API — существует только один
входной ArrayBuffer или TypedArray.
При необходимости подписать логически разделённые части данных используются три основных подхода:
Каждый из подходов влияет на безопасность и однозначность восстановления исходной структуры.
Подпись создаётся через метод:
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, что
делает невозможным восстановление исходной структуры.
Для устранения неоднозначности каждый блок кодируется вместе с длиной:
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 часто применяется для подписания составных сообщений, так как он устойчив к линейным преобразованиям и хорошо работает с агрегированными структурами.
Пример формирования сообщения с разделителями:
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) принцип остаётся тем же: подписывается итоговый
байтовый массив.
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:
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
);
};
Такой подход сохраняет корректность криптографической операции, но требует памяти пропорционально размеру данных.
При подписи нескольких блоков часто требуется различать контексты:
Для этого добавляется префикс контекста:
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
);
Такое разделение предотвращает перенос подписи между различными доменами данных даже при совпадении полезной нагрузки.