307 lines
12 KiB
TypeScript
307 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 }),
|
|
...(m.thread_id !== undefined && { threadId: m.thread_id }),
|
|
...(m.folder_id !== undefined && { folderId: m.folder_id }),
|
|
snapshot: item.snapshot,
|
|
control: item.control,
|
|
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);
|
|
}
|
|
});
|
|
});
|