Files

460 lines
21 KiB
JavaScript

// 协议测试向量生成器。
//
// 这是协议的「独立参照实现」:只依赖 node:crypto,故意不引用 @webdrop/core,也不引用服务端代码。
// 之后 @webdrop/core(WebCrypto)、服务端测试(C# 的 HKDF/AesGcm)都要复现这里的每一个字节;
// 三套互不相干的实现一致,才说明协议文档写得无歧义。
//
// 用法: node test-vectors/generate.mjs 生成 vectors.json
// node test-vectors/generate.mjs --check 只校验磁盘上的文件与重新生成的结果一致(CI 用)
//
// 所有输入都是固定值,输出完全确定,可以放心提交到仓库。
import { createCipheriv, createHash, createPublicKey, hkdfSync, verify as verifySignature } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const outPath = join(here, 'vectors.json');
// ───────────── 基础工具 ─────────────
const hex = (b) => Buffer.from(b).toString('hex');
const unhex = (s) => Buffer.from(s, 'hex');
const b64url = (b) => Buffer.from(b).toString('base64url');
const sha256 = (b) => createHash('sha256').update(b).digest();
const seq = (start, n) => Buffer.from(Array.from({ length: n }, (_, i) => (start + i) & 0xff));
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; };
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; };
const hkdf = (ikm, salt, info) => Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), 32));
// ───────────── 协议常量(protocol.md) ─────────────
const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8, CONTROL: 16 };
const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576];
const MAX_FRAME_DATA = 1048576 - 4;
const LABEL = {
enc: 'webdrop/v1/enc',
read: 'webdrop/v1/token/read',
write: 'webdrop/v1/token/write',
owner: 'webdrop/v1/token/owner',
item: 'webdrop/v1/item',
label: 'webdrop/v1/label',
};
// ───────────── 固定输入 ─────────────
const channelId = seq(0x00, 16);
const K = seq(0x20, 32);
const A = seq(0x40, 32);
const W = seq(0x60, 32);
const S = seq(0xc0, 32); // 终端密钥
const endpointHash = sha256(S);
const itemA = seq(0xa0, 16);
const itemB = seq(0xb0, 16);
const itemC = seq(0xd0, 16);
// ───────────── 密钥派生 ─────────────
const kEnc = hkdf(K, channelId, LABEL.enc);
const tRead = hkdf(K, channelId, LABEL.read);
const tWrite = hkdf(K, channelId, LABEL.write);
const tOwner = hkdf(A, channelId, LABEL.owner);
const kLabel = hkdf(kEnc, channelId, LABEL.label);
const itemKey = (itemId) => hkdf(kEnc, itemId, LABEL.item);
// ───────────── 帧层 ─────────────
function tierFor(total) {
const t = TIERS.find((x) => x >= total);
if (!t) throw new Error('data 超出单帧上限');
return t;
}
function padPlain(data) {
const tier = tierFor(4 + data.length);
return Buffer.concat([u32(data.length), data, Buffer.alloc(tier - 4 - data.length)]);
}
const ivFor = (index) => Buffer.concat([Buffer.alloc(8), u32(index)]);
function header(flags, itemId, index, payloadLen) {
return Buffer.concat([Buffer.from([1, flags]), itemId, u32(index), u32(payloadLen)]);
}
const aadFor = (chId, flags, itemId, index) =>
Buffer.concat([Buffer.from('WD1', 'ascii'), chId, Buffer.from([1, flags]), itemId, u32(index)]);
function gcmEncrypt(key, iv, aad, plaintext) {
const c = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
c.setAAD(aad);
const ct = Buffer.concat([c.update(plaintext), c.final()]);
return Buffer.concat([ct, c.getAuthTag()]);
}
/** 加密一帧,返回完整封装。flags 必须自己带上 ENC。 */
function sealFrame({ chId = channelId, itemId, index, flags, data }) {
const plaintext = padPlain(data);
const payload = gcmEncrypt(itemKey(itemId), ivFor(index), aadFor(chId, flags, itemId, index), plaintext);
return { plaintext, envelope: Buffer.concat([header(flags, itemId, index, payload.length), payload]) };
}
// ───────────── 条目层 ─────────────
function metaJson(m) {
// 字段顺序固定,undefined 的字段省略 —— TS 实现必须产出完全相同的字节
const o = { v: 1, kind: m.kind };
if (m.name !== undefined) o.name = m.name;
if (m.mime !== undefined) o.mime = m.mime;
if (m.size !== undefined) o.size = m.size;
o.ts = m.ts;
if (m.dev !== undefined) o.dev = m.dev;
if (m.threadId !== undefined) o.thread_id = m.threadId;
if (m.folderId !== undefined) o.folder_id = m.folderId;
return Buffer.from(JSON.stringify(o), 'utf8');
}
/** 条目编码的参照实现:小内容单帧(非 GATED),放不下则多帧 GATED。 */
function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false, control = false }) {
const mj = metaJson(meta);
const head = Buffer.concat([u16(mj.length), mj]);
const cap0 = maxFrameData - head.length;
if (cap0 < 0) throw new Error('meta 过大');
const frames = [];
if (content.length <= cap0 && !forceGated) {
frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0) | (control ? FLAG.CONTROL : 0), data: Buffer.concat([head, content]) });
return { gated: false, frames };
}
if (snapshot) throw new Error('快照不能是 GATED');
const chunks = [content.subarray(0, Math.min(cap0, content.length))];
for (let off = chunks[0].length; off < content.length; off += maxFrameData) {
chunks.push(content.subarray(off, Math.min(off + maxFrameData, content.length)));
}
chunks.forEach((chunk, i) => {
const last = i === chunks.length - 1;
frames.push({
index: i,
flags: FLAG.GATED | FLAG.ENC | (last ? FLAG.FINAL : 0),
data: i === 0 ? Buffer.concat([head, chunk]) : chunk,
});
});
return { gated: true, frames };
}
// ───────────── 生成向量 ─────────────
const vectors = { version: 1 };
// 固定 ECDSA P-256 验签向量。签名只用于测试,对应私钥不进入向量。
const signatureFixture = {
x: 'OJVV0pkDZHH7F92rxIQt1EuXk4ZesLwoWTaMSANt79A',
y: 'HRP3ZgqoD_8Iv42Mw1oflcJUKeq3H1gEbbOLNJoC_zs',
kind: 'chat.create',
payload: '01020304deadbeef',
signature: '9dad329614d98c97acc5a60622329e57e111fd7579edc7e4bcc535476aae281cc7e3ee00fa2d5d901af525875af653d74bd441ca5dd54d00081b51b7c3b5adcb',
};
const signatureKind = Buffer.from(signatureFixture.kind, 'ascii');
const signaturePayload = unhex(signatureFixture.payload);
const signatureMessage = Buffer.concat([
Buffer.from([0x57, 0x44, 0x53, 0x01]), u16(signatureKind.length), signatureKind,
u32(signaturePayload.length), signaturePayload,
]);
const signaturePublicKey = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: signatureFixture.x, y: signatureFixture.y }, format: 'jwk' });
if (!verifySignature('sha256', signatureMessage, { key: signaturePublicKey, dsaEncoding: 'ieee-p1363' }, unhex(signatureFixture.signature)))
throw new Error('固定签名向量无效');
vectors.signatures = { ...signatureFixture, message: hex(signatureMessage) };
const eventDigests = [seq(0x31, 32), seq(0x71, 32)];
const eventManifestInput = Buffer.concat([Buffer.from('WD1M', 'ascii'), u32(eventDigests.length),
...eventDigests.flatMap((digest, index) => [u32(index), digest])]);
const eventFields = {
space_id: hex(channelId), channel_id: '01234567-89ab-cdef-0123-456789abcdef', username: 'alice',
device_id: '11111111-2222-3333-4444-555555555555', event_id: hex(itemC), kind: 'chat.edit',
domain: 'chat', epoch: 2, item_id: hex(itemA), frame_count: 2, target_item_id: hex(itemB),
};
const eventKind = Buffer.from(eventFields.kind, 'ascii');
const eventChannel = Buffer.from(eventFields.channel_id, 'ascii');
const eventUser = Buffer.from(eventFields.username, 'ascii');
const eventDevice = Buffer.from(eventFields.device_id, 'ascii');
const eventTarget = unhex(eventFields.target_item_id);
const eventManifest = sha256(eventManifestInput);
const eventPayload = Buffer.concat([
channelId, Buffer.from([eventChannel.length]), eventChannel, Buffer.from([eventUser.length]), eventUser,
Buffer.from([eventDevice.length]), eventDevice, itemC, Buffer.from([eventKind.length]), eventKind,
Buffer.from([1]), u32(eventFields.epoch), itemA, u32(eventFields.frame_count), eventManifest,
Buffer.from([1]), eventTarget,
]);
vectors.content_event = { ...eventFields, frame_digests: eventDigests.map(hex), manifest_hash: hex(eventManifest), payload: hex(eventPayload) };
vectors.inputs = {
channel_id: hex(channelId),
channel_id_b64: b64url(channelId),
k: hex(K),
a: hex(A),
w: hex(W),
endpoint_secret: hex(S),
item_a: hex(itemA),
item_b: hex(itemB),
item_c: hex(itemC),
};
vectors.kdf = {
k_enc: hex(kEnc),
t_read: hex(tRead),
t_write: hex(tWrite),
t_owner: hex(tOwner),
k_label: hex(kLabel),
read_hash: hex(sha256(tRead)),
write_hash: hex(sha256(tWrite)),
owner_hash: hex(sha256(tOwner)),
endpoint_hash: hex(endpointHash),
// raw 模式:T_write 就是随机的 W 本身
raw_write_token: hex(W),
raw_write_hash: hex(sha256(W)),
};
vectors.item_keys = [itemA, itemB, itemC].map((id) => ({ item_id: hex(id), k_item: hex(itemKey(id)) }));
vectors.iv = [0, 1, 255, 65536, 4294967295].map((i) => ({ index: i, iv: hex(ivFor(i)) }));
vectors.aad = [
{ flags: FLAG.FINAL | FLAG.ENC, item_id: hex(itemA), index: 0 },
{ flags: FLAG.GATED | FLAG.ENC, item_id: hex(itemA), index: 7 },
{ flags: FLAG.GATED | FLAG.ENC | FLAG.FINAL, item_id: hex(itemB), index: 4294967295 },
{ flags: FLAG.FINAL | FLAG.ENC | FLAG.SNAPSHOT, item_id: hex(itemB), index: 0 },
].map((c) => ({ ...c, aad: hex(aadFor(channelId, c.flags, unhex(c.item_id), c.index)) }));
// padding:覆盖每个档位的边界
vectors.padding = [0, 1, 1019, 1020, 1021, 4091, 4092, 4093, 16380, 16381, 65532, 65533, 262140, 262141, 1048572].map(
(n) => ({ data_len: n, plaintext_len: tierFor(4 + n) }),
);
// 单帧向量:明文、padding 后的明文、完整封装
const frameCases = [
{ name: 'empty', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.alloc(0) },
{ name: 'short_text', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.from('hello', 'utf8') },
{ name: 'exact_tier_1k', item: itemB, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: seq(1, 1020) },
{ name: 'just_over_1k', item: itemB, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: seq(2, 1021) },
{ name: 'gated_middle', item: itemC, index: 7, flags: FLAG.GATED | FLAG.ENC, data: seq(3, 300) },
{ name: 'gated_final_high_index', item: itemC, index: 4294967295, flags: FLAG.GATED | FLAG.ENC | FLAG.FINAL, data: seq(4, 17) },
{ name: 'snapshot', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC | FLAG.SNAPSHOT, data: Buffer.from('{"a":1}', 'utf8') },
];
vectors.frames = frameCases.map((c) => {
const { plaintext, envelope } = sealFrame({ itemId: c.item, index: c.index, flags: c.flags, data: c.data });
return {
name: c.name,
item_id: hex(c.item),
index: c.index,
flags: c.flags,
data: hex(c.data),
plaintext: hex(plaintext),
envelope: hex(envelope),
};
});
// 条目向量:meta + 内容 + 分帧
const TS = 1700000000000;
const itemCases = [
{ name: 'workspace_control', itemId: itemC, meta: { kind: 'other', ts: TS }, control: true, content: Buffer.from('{"app":"webdrop.workspace","v":1,"op":"thread.create"}', 'utf8') },
{
name: 'text_small',
itemId: itemA,
meta: { kind: 'text', ts: TS },
content: Buffer.from('hello, 世界', 'utf8'),
},
{
name: 'text_with_device',
itemId: itemB,
meta: { kind: 'text', ts: TS, dev: '我的 iPhone' },
content: Buffer.from('带设备名的文本', 'utf8'),
},
{
name: 'json_snapshot',
itemId: itemA,
meta: { kind: 'json', ts: TS },
content: Buffer.from('{"theme":"dark","n":[1,2,3]}', 'utf8'),
snapshot: true,
},
{
name: 'file_single_frame',
itemId: itemC,
meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS },
content: Buffer.from('hello world', 'utf8'),
},
{
name: 'file_in_thread_folder',
itemId: itemC,
meta: { kind: 'file', name: 'readme.txt', mime: 'text/plain', size: 11, ts: TS, dev: 'Windows 11',
threadId: '01234567-89ab-cdef-0123-456789abcdef', folderId: 'fedcba98-7654-3210-fedc-ba9876543210' },
content: Buffer.from('thread test', 'utf8'),
},
{
name: 'empty_text',
itemId: itemB,
meta: { kind: 'text', ts: TS },
content: Buffer.alloc(0),
},
];
// 多帧:用很小的单帧数据上限,避免向量过大。size 已知。
const fileContent = Buffer.from(Array.from({ length: 2500 }, (_, i) => i % 251));
const fileMeta = { kind: 'file', name: 'a.bin', mime: 'application/octet-stream', size: fileContent.length, ts: TS };
const smallCap = 1000;
const cap0 = smallCap - (2 + metaJson(fileMeta).length);
itemCases.push(
{ name: 'file_multi_frame', itemId: itemC, meta: fileMeta, content: fileContent, maxFrameData: smallCap },
// 边界:内容恰好放得进第 0 帧 → 仍是单帧非 GATED;多一个字节 → 变成两帧 GATED
{
name: 'boundary_fits_exactly',
itemId: itemA,
meta: { kind: 'file', name: 'b', ts: TS },
content: seq(5, smallCap - (2 + metaJson({ kind: 'file', name: 'b', ts: TS }).length)),
maxFrameData: smallCap,
},
{
name: 'boundary_one_byte_over',
itemId: itemA,
meta: { kind: 'file', name: 'b', ts: TS },
content: seq(5, smallCap - (2 + metaJson({ kind: 'file', name: 'b', ts: TS }).length) + 1),
maxFrameData: smallCap,
},
// 显式要求 GATED(例如带下载上限的小内容)
{
name: 'small_forced_gated',
itemId: itemB,
meta: { kind: 'file', name: 'c.txt', ts: TS },
content: Buffer.from('gated small', 'utf8'),
forceGated: true,
},
);
vectors.items = itemCases.map((c) => {
const plan = encodeItem(c);
const envelopes = plan.frames.map((f) =>
sealFrame({ itemId: c.itemId, index: f.index, flags: f.flags, data: f.data }).envelope,
);
return {
name: c.name,
item_id: hex(c.itemId),
meta: JSON.parse(metaJson(c.meta).toString('utf8')),
meta_json: metaJson(c.meta).toString('utf8'),
content: hex(c.content),
max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA,
snapshot: !!c.snapshot,
force_gated: !!c.forceGated,
control: !!c.control,
gated: plan.gated,
frames: envelopes.map(hex),
};
});
vectors.item_notes = { multi_frame_first_frame_content_capacity: cap0 };
// raw 模式:负载原样,没有 ENC 标志,没有 meta
vectors.raw_frames = [
{ name: 'raw_text', item_id: hex(itemA), index: 0, flags: FLAG.FINAL, payload: hex(Buffer.from('plain 明文', 'utf8')) },
{ name: 'raw_gated_first', item_id: hex(itemB), index: 0, flags: FLAG.GATED, payload: hex(seq(9, 40)) },
{ name: 'raw_gated_last', item_id: hex(itemB), index: 1, flags: FLAG.GATED | FLAG.FINAL, payload: hex(seq(50, 10)) },
].map((c) => {
const payload = unhex(c.payload);
return { ...c, envelope: hex(Buffer.concat([header(c.flags, unhex(c.item_id), c.index, payload.length), payload])) };
});
// 设备名(固定 IV 仅用于向量,真实实现必须用随机 IV)
const labelIv = seq(0x10, 12);
vectors.labels = ['我的 iPhone', 'Laptop', 'x'.repeat(228)].map((name) => {
const aad = Buffer.concat([Buffer.from('WD1L', 'ascii'), channelId, endpointHash]);
const sealed = gcmEncrypt(kLabel, labelIv, aad, Buffer.from(name, 'utf8'));
return { name, iv: hex(labelIv), aad: hex(aad), blob: hex(Buffer.concat([labelIv, sealed])) };
});
// 链接
const chB64 = b64url(channelId);
vectors.links = {
valid: [
{ url: `https://app.example.test/c/${chB64}#k=${b64url(K)}&a=${b64url(A)}`, id: chB64, mode: 'enc', role: 'owner', k: hex(K), a: hex(A) },
{ url: `https://app.example.test/c/${chB64}#k=${b64url(K)}`, id: chB64, mode: 'enc', role: 'member', k: hex(K) },
{ url: `https://app.example.test/c/${chB64}#a=${b64url(A)}&w=${b64url(W)}`, id: chB64, mode: 'raw', role: 'owner', a: hex(A), w: hex(W) },
{ url: `https://app.example.test/c/${chB64}#w=${b64url(W)}`, id: chB64, mode: 'raw', role: 'member', w: hex(W) },
{ url: `https://app.example.test/c/${chB64}`, id: chB64, mode: 'raw', role: 'member' },
{ url: `https://app.example.test/base/c/${chB64}/#k=${b64url(K)}&unknown=1`, id: chB64, mode: 'enc', role: 'member', k: hex(K) },
],
invalid: [
{ name: 'no_channel_path', url: `https://app.example.test/x/${chB64}#k=${b64url(K)}` },
{ name: 'short_id', url: `https://app.example.test/c/abc#k=${b64url(K)}` },
{ name: 'non_canonical_id', url: `https://app.example.test/c/AAAAAAAAAAAAAAAAAAAAAB#k=${b64url(K)}` },
{ name: 'short_key', url: `https://app.example.test/c/${chB64}#k=${b64url(K.subarray(0, 16))}` },
{ name: 'key_in_query_not_fragment', url: `https://app.example.test/c/${chB64}?k=${b64url(K)}` },
{ name: 'enc_with_raw_write_token', url: `https://app.example.test/c/${chB64}#k=${b64url(K)}&w=${b64url(W)}` },
{ name: 'padding_in_key', url: `https://app.example.test/c/${chB64}#k=${Buffer.from(K).toString('base64')}` },
],
};
// 日志记录的线格式
const tombItem = itemB;
const frameForLog = sealFrame({ itemId: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.from('hello', 'utf8') }).envelope;
vectors.log_records = [
{ name: 'frame', hex: hex(Buffer.concat([u64(1), Buffer.from([1]), frameForLog])), parsed: { kind: 'frame', seq: 1, envelope: hex(frameForLog) } },
{ name: 'tombstone_download_limit', hex: hex(Buffer.concat([u64(7), Buffer.from([2]), tombItem, Buffer.from([3])])), parsed: { kind: 'tombstone', seq: 7, item_id: hex(tombItem), reason: 3 } },
{ name: 'tombstone_superseded', hex: hex(Buffer.concat([u64(8), Buffer.from([2]), itemA, Buffer.from([4])])), parsed: { kind: 'tombstone', seq: 8, item_id: hex(itemA), reason: 4 } },
{
name: 'announce',
hex: hex(Buffer.concat([u64(9), Buffer.from([3]), itemC, Buffer.from([FLAG.GATED | FLAG.ENC]), u64(1700000000123)])),
parsed: { kind: 'announce', seq: 9, item_id: hex(itemC), flags: FLAG.GATED | FLAG.ENC, created_at: 1700000000123 },
},
];
vectors.log_invalid = [
{ name: 'truncated_header', hex: hex(Buffer.concat([u64(1)])) },
{ name: 'unknown_kind', hex: hex(Buffer.concat([u64(1), Buffer.from([9]), Buffer.alloc(8)])) },
{ name: 'truncated_tombstone', hex: hex(Buffer.concat([u64(2), Buffer.from([2]), itemA])) },
{ name: 'frame_length_mismatch', hex: hex(Buffer.concat([u64(1), Buffer.from([1]), frameForLog.subarray(0, frameForLog.length - 1)])) },
];
// 负例:每一条都必须被接收方拒绝
const base = unhex(vectors.frames.find((f) => f.name === 'short_text').envelope);
const mutate = (fn) => { const b = Buffer.from(base); fn(b); return hex(b); };
const otherChannel = seq(0x70, 16);
vectors.negative = [
{ name: 'flip_last_payload_byte', envelope: mutate((b) => { b[b.length - 1] ^= 1; }), expect: 'auth_failed' },
{ name: 'flip_first_payload_byte', envelope: mutate((b) => { b[26] ^= 1; }), expect: 'auth_failed' },
{ name: 'flip_tag_byte', envelope: mutate((b) => { b[b.length - 16] ^= 0x80; }), expect: 'auth_failed' },
{ name: 'clear_final_flag', envelope: mutate((b) => { b[1] &= ~FLAG.FINAL; }), expect: 'auth_failed' },
{ name: 'add_gated_flag', envelope: mutate((b) => { b[1] |= FLAG.GATED; }), expect: 'auth_failed' },
{ name: 'add_snapshot_flag', envelope: mutate((b) => { b[1] |= FLAG.SNAPSHOT; }), expect: 'auth_failed' },
{ name: 'change_frame_index', envelope: mutate((b) => { b[21] ^= 1; }), expect: 'auth_failed' },
{ name: 'change_item_id', envelope: mutate((b) => { b[2] ^= 1; }), expect: 'auth_failed' },
{ name: 'wrong_channel', envelope: hex(base), channel_id: hex(otherChannel), expect: 'auth_failed' },
{ name: 'truncated_payload', envelope: hex(base.subarray(0, base.length - 1)), expect: 'bad_envelope' },
{ name: 'trailing_byte', envelope: hex(Buffer.concat([base, Buffer.from([0])])), expect: 'bad_envelope' },
{ name: 'header_only', envelope: hex(base.subarray(0, 26)), expect: 'bad_envelope' },
{ name: 'too_short', envelope: hex(base.subarray(0, 10)), expect: 'bad_envelope' },
{ name: 'bad_version', envelope: mutate((b) => { b[0] = 2; }), expect: 'bad_envelope' },
{ name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x20; }), expect: 'bad_envelope' },
{ name: 'payload_len_mismatch', envelope: mutate((b) => { b[25] ^= 1; }), expect: 'bad_envelope' },
// 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧)
{
name: 'injected_plaintext_frame',
envelope: hex(Buffer.concat([header(FLAG.FINAL, itemA, 0, 5), Buffer.from('evil!')])),
expect: 'wrong_mode',
},
];
// ───────────── 输出 ─────────────
const json = JSON.stringify(vectors, null, 2) + '\n';
if (process.argv.includes('--check')) {
const onDisk = readFileSync(outPath, 'utf8');
if (onDisk !== json) {
console.error('vectors.json 与重新生成的结果不一致,请运行 node test-vectors/generate.mjs 并提交');
process.exit(1);
}
console.log('vectors.json 是最新的');
} else {
writeFileSync(outPath, json);
console.log(`已生成 ${outPath}(${vectors.frames.length} 个单帧,${vectors.items.length} 个条目,${vectors.negative.length} 个负例)`);
}