// 协议测试向量生成器。 // // 这是协议的「独立参照实现」:只依赖 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, hkdfSync } 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 }; 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} 个负例)`); }