Files
WebDrop/packages/core/test/vectors.test.ts
T
2026-10-02 00:59:22 +08:00

304 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { fromBase64Url, toBase64Url } from '../src/bytes.js';
import {
IntegrityError,
buildLink,
parseLink,
parseLogRecord,
type ItemEvent,
} from '../src/index.js';
import {
deriveChannelKeys,
deriveItemKey,
deriveOwnerToken,
deriveRawChannelSecrets,
deriveRawItemKey,
deriveRawOwnerToken,
frameAad,
frameIv,
padPlaintext,
parseEnvelope,
sealFrame,
sha256,
tierFor,
unpadPlaintext,
encodeEnvelope,
} from '../src/internal.js';
import { openFrame } from '../src/frame.js';
import { framesOf, h, inputs, openVectorSession, toHex, vectors } from './helpers.js';
/**
* 这些向量由 test-vectors/generate.mjs 用 node:crypto 独立生成(不引用本包),
* 服务端的 C# 测试用 .NET 的密码学实现再复现一遍。
* 三套互不相干的实现一致,才说明 protocol.md 没有歧义。
*/
describe('密钥派生', () => {
it('原始字节与向量一致', async () => {
const secrets = await deriveRawChannelSecrets(inputs.channelId, inputs.k);
expect(toHex(secrets.kEnc)).toBe(vectors.kdf.k_enc);
expect(toHex(secrets.tRead)).toBe(vectors.kdf.t_read);
expect(toHex(secrets.tWrite)).toBe(vectors.kdf.t_write);
expect(toHex(secrets.kLabel)).toBe(vectors.kdf.k_label);
expect(toHex(await deriveRawOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner);
for (const item of vectors.item_keys) {
expect(toHex(await deriveRawItemKey(secrets.kEnc, h(item.item_id)))).toBe(item.k_item);
}
});
it('生产路径(不可导出的 CryptoKey)得到同样的令牌', async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
expect(toHex(keys.readToken)).toBe(vectors.kdf.t_read);
expect(toHex(keys.writeToken)).toBe(vectors.kdf.t_write);
expect(toHex(await deriveOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner);
});
it('生产路径里的密钥确实不可导出', async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
const itemKey = await deriveItemKey(keys.encRoot, inputs.itemA);
expect(itemKey.extractable).toBe(false);
expect(keys.encRoot.extractable).toBe(false);
expect(keys.labelKey.extractable).toBe(false);
await expect(crypto.subtle.exportKey('raw', itemKey)).rejects.toThrow();
});
it('令牌哈希与向量一致', async () => {
expect(toHex(await sha256(h(vectors.kdf.t_read)))).toBe(vectors.kdf.read_hash);
expect(toHex(await sha256(h(vectors.kdf.t_write)))).toBe(vectors.kdf.write_hash);
expect(toHex(await sha256(h(vectors.kdf.t_owner)))).toBe(vectors.kdf.owner_hash);
expect(toHex(await sha256(h(vectors.kdf.raw_write_token)))).toBe(vectors.kdf.raw_write_hash);
expect(toHex(await sha256(inputs.endpointSecret))).toBe(vectors.kdf.endpoint_hash);
});
});
describe('IV 与 AAD', () => {
it('IV = 8 字节零 ‖ frame_index', () => {
for (const v of vectors.iv) expect(toHex(frameIv(v.index))).toBe(v.iv);
});
it('AAD = "WD1" ‖ channel_id ‖ 头部前 22 字节', () => {
for (const c of vectors.aad) {
// 只含头部的封装(payload_len = 0),AAD 只取前 22 字节
const header = encodeEnvelope(c.flags, h(c.item_id), c.index, new Uint8Array(0));
expect(toHex(frameAad(inputs.channelId, header))).toBe(c.aad);
}
});
});
describe('padding', () => {
it('每个档位边界的明文长度与向量一致', () => {
for (const p of vectors.padding) {
expect(tierFor(4 + p.data_len)).toBe(p.plaintext_len);
const padded = padPlaintext(new Uint8Array(p.data_len));
expect(padded.length).toBe(p.plaintext_len);
expect(unpadPlaintext(padded).length).toBe(p.data_len);
}
});
it('拒绝非零填充、越界长度和非档位长度', () => {
const padded = padPlaintext(new Uint8Array(10));
const dirty = padded.slice();
dirty[dirty.length - 1] = 1;
expect(() => unpadPlaintext(dirty)).toThrowError(IntegrityError);
const lying = padded.slice();
lying.set([0xff, 0xff, 0xff, 0xff], 0);
expect(() => unpadPlaintext(lying)).toThrowError(IntegrityError);
expect(() => unpadPlaintext(new Uint8Array(1000))).toThrowError(IntegrityError); // 不是档位
expect(() => unpadPlaintext(new Uint8Array(2))).toThrowError(IntegrityError);
});
it('超过单帧上限时拒绝', () => {
expect(() => padPlaintext(new Uint8Array(1048573))).toThrow(RangeError);
expect(padPlaintext(new Uint8Array(1048572)).length).toBe(1048576);
});
});
describe('单帧加解密', () => {
for (const f of vectors.frames) {
it(`${f.name}:密文逐字节一致,并能解回原文`, async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
const itemKey = await deriveItemKey(keys.encRoot, h(f.item_id));
const sealed = await sealFrame(itemKey, inputs.channelId, {
flags: f.flags,
itemId: h(f.item_id),
index: f.index,
data: h(f.data),
});
expect(toHex(sealed)).toBe(f.envelope);
const opened = await openFrame(itemKey, inputs.channelId, parseEnvelope(h(f.envelope)));
expect(toHex(opened)).toBe(f.data);
});
}
});
describe('条目分帧', () => {
for (const item of vectors.items) {
it(`${item.name}:分帧与 GATED 决策和向量一致`, async () => {
const session = await openVectorSession();
const m = item.meta;
const { kind, name, mime, size } = m;
const plan = session.encryptBytes(
h(item.content),
{ kind, ...(name !== undefined && { name }), ...(mime !== undefined && { mime }), ...(size !== undefined && { size }) },
{
itemId: h(item.item_id),
ts: m.ts,
...(m.dev !== undefined && { dev: m.dev }),
snapshot: item.snapshot,
gated: item.force_gated ? true : 'auto',
maxFrameData: item.max_frame_data,
},
);
const frames = await framesOf(plan);
expect(frames.map((f) => toHex(f.bytes))).toEqual(item.frames);
expect(plan.gated).toBe(item.gated);
expect(frames.map((f) => f.final)).toEqual(frames.map((_, i) => i === frames.length - 1));
});
}
it('边界:恰好放得进第 0 帧是单帧,多一个字节就变成两帧 GATED', () => {
const fits = vectors.items.find((i: { name: string }) => i.name === 'boundary_fits_exactly');
const over = vectors.items.find((i: { name: string }) => i.name === 'boundary_one_byte_over');
expect([fits.gated, fits.frames.length]).toEqual([false, 1]);
expect([over.gated, over.frames.length]).toEqual([true, 2]);
});
});
describe('raw 帧', () => {
it('封装与向量一致', () => {
for (const f of vectors.raw_frames) {
const built = encodeEnvelope(f.flags, h(f.item_id), f.index, h(f.payload));
expect(toHex(built)).toBe(f.envelope);
}
});
});
describe('设备名', () => {
it('固定 IV 下的密文一致,并能解回', async () => {
const session = await openVectorSession();
for (const l of vectors.labels) {
const sealed = await session.sealLabel(l.name, { iv: h(l.iv) });
expect(toHex(sealed)).toBe(l.blob);
expect(sealed.length).toBeLessThanOrEqual(256);
expect(await session.openLabel(sealed, session.endpointHash)).toBe(l.name);
}
});
it('换一个终端的哈希就解不开(AAD 绑定终端)', async () => {
const session = await openVectorSession();
const sealed = await session.sealLabel('我的 iPhone');
await expect(session.openLabel(sealed, new Uint8Array(32))).rejects.toThrowError(IntegrityError);
});
it('超过 228 字节 UTF-8 时拒绝', async () => {
const session = await openVectorSession();
await expect(session.sealLabel('x'.repeat(229))).rejects.toThrow();
await expect(session.sealLabel('x'.repeat(228))).resolves.toHaveLength(256);
});
});
describe('链接', () => {
it('合法链接解析结果与向量一致', () => {
for (const v of vectors.links.valid) {
const link = parseLink(v.url);
expect(link.id).toBe(v.id);
expect(link.mode).toBe(v.mode);
expect(link.role).toBe(v.role);
expect(link.k && toHex(link.k)).toBe(v.k);
expect(link.a && toHex(link.a)).toBe(v.a);
expect(link.w && toHex(link.w)).toBe(v.w);
}
});
it('非法链接全部被拒绝', () => {
for (const v of vectors.links.invalid) {
expect(() => parseLink(v.url), v.name).toThrow();
}
});
it('生成的链接能被解析回原值', () => {
const url = buildLink('https://app.example.test/', { id: inputs.channelIdB64, k: inputs.k, a: inputs.a });
expect(url).toBe(`https://app.example.test/c/${inputs.channelIdB64}#k=${toBase64Url(inputs.k)}&a=${toBase64Url(inputs.a)}`);
const back = parseLink(url);
expect(toHex(back.k!)).toBe(toHex(inputs.k));
expect(back.role).toBe('owner');
});
});
describe('日志记录', () => {
it('解析结果与向量一致', () => {
for (const r of vectors.log_records) {
const rec = parseLogRecord(h(r.hex));
const p = r.parsed;
expect(rec.kind).toBe(p.kind);
expect(rec.seq).toBe(p.seq);
if (rec.kind === 'frame') expect(toHex(rec.envelope)).toBe(p.envelope);
if (rec.kind === 'tombstone') {
expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id);
expect(['deleted', 'expired', 'evicted', 'download_limit', 'superseded'][p.reason]).toBe(rec.reason);
}
if (rec.kind === 'announce') {
expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id);
expect(rec.flags).toBe(p.flags);
expect(rec.createdAt).toBe(p.created_at);
}
}
});
it('损坏的记录被拒绝', () => {
for (const r of vectors.log_invalid) {
expect(() => parseLogRecord(h(r.hex)), r.name).toThrowError(IntegrityError);
}
});
});
describe('负例:必须被接收方拒绝', () => {
for (const n of vectors.negative) {
it(`${n.name} → ${n.expect}`, async () => {
const envelope = h(n.envelope);
const channelId = n.channel_id ? h(n.channel_id) : inputs.channelId;
if (n.expect === 'bad_envelope') {
expect(() => parseEnvelope(envelope)).toThrowError(IntegrityError);
return;
}
const env = parseEnvelope(envelope);
const keys = await deriveChannelKeys(channelId === inputs.channelId ? inputs.channelId : inputs.channelId, inputs.k);
// 注意:wrong_channel 用的是「错误的 channel_id 参与 AAD」,密钥仍由真实频道派生
const itemKey = await deriveItemKey(keys.encRoot, env.itemId);
const failure = await openFrame(itemKey, channelId, env).then(
() => null,
(e: unknown) => e,
);
expect(failure).toBeInstanceOf(IntegrityError);
expect((failure as IntegrityError).reason).toBe(n.expect);
});
}
it('装配器对每个负例都产生 rejected 事件,而不是抛异常或产出内容', async () => {
const session = await openVectorSession();
const reader = session.createItemReader();
// 这两个负例在到达解密之前就违反了结构规则(非 GATED 条目必须是单帧且带 FINAL),
// 装配器先以 bad_envelope 拒绝;直接 openFrame 时则是 auth_failed(上面已断言)
const structural = new Set(['clear_final_flag', 'change_frame_index']);
for (const n of vectors.negative) {
if (n.channel_id) continue; // 这一条是换了会话的 channel_id,装配器视角下等同于正常帧
const events: ItemEvent[] = await reader.pushFrame(h(n.envelope));
expect(events, n.name).toHaveLength(1);
const ev = events[0]!;
expect(ev.type, n.name).toBe('rejected');
if (ev.type === 'rejected') expect(ev.reason, n.name).toBe(structural.has(n.name) ? 'bad_envelope' : n.expect);
}
});
});