refactor(core): drop per-terminal device labels
Remove sealLabel/openLabel and their label constants; devices are no longer named per channel. Rename the create setting seats to max_members and point the e2e join helper at POST /join.
This commit is contained in:
1 parent
d946fc5e8a
commit
5fd4ddd411
6 files changed
+12
-171
No files matched your search
@@ -51,7 +51,6 @@ ws.onmessage = async (m) => {
|
|||||||
| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 |
|
| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 |
|
||||||
| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 |
|
| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 |
|
||||||
| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** |
|
| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** |
|
||||||
| 设备名加解密 | 复制、下载(把流写到文件)、预览 |
|
|
||||||
| 日志记录解析 | **Cap 验证码**(见下文) |
|
| 日志记录解析 | **Cap 验证码**(见下文) |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -67,7 +66,6 @@ ws.onmessage = async (m) => {
|
|||||||
| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 |
|
| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 |
|
||||||
| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` |
|
| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` |
|
||||||
| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 |
|
| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 |
|
||||||
| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 |
|
|
||||||
| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) |
|
| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) |
|
||||||
|
|
||||||
### 发送
|
### 发送
|
||||||
@@ -91,7 +89,7 @@ ws.onmessage = async (m) => {
|
|||||||
| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED |
|
| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED |
|
||||||
| `itemId` | 重试和续传必须复用同一个 id |
|
| `itemId` | 重试和续传必须复用同一个 id |
|
||||||
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
|
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
|
||||||
| `ts`, `dev` | 覆盖时间戳和设备名 |
|
| `ts` | 覆盖时间戳 |
|
||||||
|
|
||||||
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
|
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
|
||||||
|
|
||||||
@@ -131,13 +129,12 @@ const { complete, nextIndex, meta } = await result;
|
|||||||
| 调用 | 说明 |
|
| 调用 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 |
|
| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 |
|
||||||
| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) |
|
|
||||||
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
|
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
|
||||||
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
|
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
|
||||||
|
|
||||||
## 必须注意的几点
|
## 必须注意的几点
|
||||||
|
|
||||||
**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它登记独立设备席位以便认证和撤销;频道成员上限按账号数计算,同一账号的多台设备不重复占名额。不持久化的话,每次打开链接都会登记一个新设备终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||||
|
|
||||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||||
|
|
||||||
@@ -167,10 +164,10 @@ npm run vectors:check # 校验 test-vectors/vectors.json 是最新的
|
|||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 |
|
| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 |
|
||||||
| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 |
|
| `session.test.ts` | 创建、链接、角色、降级防御、收发、raw 模式、条目计划 |
|
||||||
| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 |
|
| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 |
|
||||||
| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 |
|
| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 |
|
||||||
| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 |
|
| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 |
|
||||||
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 |
|
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传 |
|
||||||
|
|
||||||
另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|
另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|
||||||
@@ -38,11 +38,7 @@ export const Labels = {
|
|||||||
|
|
||||||
/** AAD 前缀。 */
|
/** AAD 前缀。 */
|
||||||
export const AAD_FRAME = 'WD1';
|
export const AAD_FRAME = 'WD1';
|
||||||
export const AAD_LABEL = 'WD1L';
|
|
||||||
|
|
||||||
/** 设备名总长上限(iv + 密文 + tag),与服务端 MaxSeatLabelBytes 一致。 */
|
|
||||||
export const MAX_LABEL_BYTES = 256;
|
|
||||||
export const LABEL_IV_SIZE = 12;
|
|
||||||
|
|
||||||
export const POW_NONCE_SIZE = 8;
|
export const POW_NONCE_SIZE = 8;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AAD_LABEL, ID_SIZE, KEY_SIZE, LABEL_IV_SIZE, MAX_LABEL_BYTES, TAG_SIZE, type ChannelMode } from './constants.js';
|
import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
|
||||||
import { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js';
|
import { randomBytes, toBase64Url, utf8, wipe, type Bytes } from './bytes.js';
|
||||||
import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js';
|
import { CapabilityError, LinkError, ModeMismatchError } from './errors.js';
|
||||||
import { encodeEnvelope, sealFrame } from './frame.js';
|
import { encodeEnvelope, sealFrame } from './frame.js';
|
||||||
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
|
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
|
||||||
import { buildLink, parseLink, type ParsedLink } from './link.js';
|
import { buildLink, parseLink, type ParsedLink } from './link.js';
|
||||||
@@ -15,7 +15,7 @@ export interface ChannelSettings {
|
|||||||
join_policy?: 'open' | 'invite';
|
join_policy?: 'open' | 'invite';
|
||||||
allow_guests?: boolean;
|
allow_guests?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
seats?: number;
|
max_members?: number;
|
||||||
members_can_upload?: boolean;
|
members_can_upload?: boolean;
|
||||||
delete_policy?: 'owner_only' | 'own' | 'anyone';
|
delete_policy?: 'owner_only' | 'own' | 'anyone';
|
||||||
ttl_seconds?: number;
|
ttl_seconds?: number;
|
||||||
@@ -331,68 +331,6 @@ export class ChannelSession {
|
|||||||
return decryptItemStream(this.#readerContext, source, opts);
|
return decryptItemStream(this.#readerContext, source, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── 设备名(protocol §10) ─────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加密设备名,作为席位名称 PUT 请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16),
|
|
||||||
* AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。
|
|
||||||
* @param opts.endpointHash 指定目标席位;所有者可给其他终端重命名。
|
|
||||||
* @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。
|
|
||||||
*/
|
|
||||||
async sealLabel(name: string, opts: { iv?: Uint8Array; endpointHash?: Uint8Array | string } = {}): Promise<Bytes> {
|
|
||||||
const endpointHash = typeof opts.endpointHash === 'string'
|
|
||||||
? fromBase64Url(opts.endpointHash, 32)
|
|
||||||
: opts.endpointHash ?? this.endpointHash;
|
|
||||||
if (!endpointHash || endpointHash.length !== 32) throw new RangeError('终端标识必须是 32 字节');
|
|
||||||
const plain = utf8(name);
|
|
||||||
if (this.mode === 'raw') {
|
|
||||||
if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长');
|
|
||||||
return plain;
|
|
||||||
}
|
|
||||||
if (plain.length === 0) throw new RangeError('设备名不能为空');
|
|
||||||
if (plain.length + LABEL_IV_SIZE + TAG_SIZE > MAX_LABEL_BYTES) {
|
|
||||||
throw new LimitError(`设备名过长(最多 ${MAX_LABEL_BYTES - LABEL_IV_SIZE - TAG_SIZE} 字节 UTF-8)`);
|
|
||||||
}
|
|
||||||
const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE);
|
|
||||||
const sealed = await globalThis.crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(endpointHash), tagLength: 128 },
|
|
||||||
(this.#keys as ChannelKeys).labelKey,
|
|
||||||
plain,
|
|
||||||
);
|
|
||||||
return concat(iv, new Uint8Array(sealed));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解密席位列表里某个终端的设备名。endpointHash 可以是 base64url 文本或字节。 */
|
|
||||||
async openLabel(blob: Uint8Array | string, endpointHash: Uint8Array | string): Promise<string> {
|
|
||||||
const raw = typeof blob === 'string' ? fromBase64Url(blob) : blob;
|
|
||||||
const hash = typeof endpointHash === 'string' ? fromBase64Url(endpointHash, 32) : endpointHash;
|
|
||||||
if (!raw || !hash) throw new IntegrityError('bad_envelope');
|
|
||||||
|
|
||||||
if (this.mode === 'raw') {
|
|
||||||
const text = tryDecodeUtf8(raw);
|
|
||||||
if (text === null) throw new IntegrityError('bad_meta');
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
if (raw.length < LABEL_IV_SIZE + TAG_SIZE + 1) throw new IntegrityError('bad_envelope');
|
|
||||||
try {
|
|
||||||
const plain = await globalThis.crypto.subtle.decrypt(
|
|
||||||
{ name: 'AES-GCM', iv: raw.slice(0, LABEL_IV_SIZE), additionalData: this.#labelAad(hash), tagLength: 128 },
|
|
||||||
(this.#keys as ChannelKeys).labelKey,
|
|
||||||
raw.slice(LABEL_IV_SIZE),
|
|
||||||
);
|
|
||||||
const text = tryDecodeUtf8(new Uint8Array(plain));
|
|
||||||
if (text === null) throw new IntegrityError('bad_meta');
|
|
||||||
return text;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof IntegrityError) throw e;
|
|
||||||
throw new IntegrityError('auth_failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#labelAad(endpointHash: Uint8Array): Bytes {
|
|
||||||
return concat(utf8(AAD_LABEL), this.idBytes, endpointHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 清理 ─────────────────────────
|
// ───────────────────────── 清理 ─────────────────────────
|
||||||
|
|
||||||
/** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */
|
/** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
|
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
|
||||||
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: headersFor(session, role) });
|
const res = await fetch(api(`/channels/${session.id}/join`), { method: 'POST', headers: headersFor(session, role) });
|
||||||
expect(res.status, await res.clone().text()).toBe(200);
|
expect(res.status, await res.clone().text()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,38 +390,6 @@ suite('端到端(真实服务端)', () => {
|
|||||||
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
|
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it('设备名:成员加密上传,所有者在席位列表里解开', async () => {
|
|
||||||
const owner = await createChannel();
|
|
||||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
||||||
await joinChannel(member, 'read');
|
|
||||||
|
|
||||||
const blob = await member.sealLabel('我的 iPhone');
|
|
||||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' },
|
|
||||||
body: blob,
|
|
||||||
});
|
|
||||||
expect(put.status).toBe(204);
|
|
||||||
|
|
||||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: headersFor(owner, 'owner') });
|
|
||||||
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
|
|
||||||
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
|
|
||||||
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
|
|
||||||
// 服务端存的是密文
|
|
||||||
expect(Buffer.from(mine.label!, 'base64url').toString('utf8')).not.toContain('iPhone');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('最长的设备名(228 字节)也被服务端接受', async () => {
|
|
||||||
const owner = await createChannel();
|
|
||||||
const blob = await owner.sealLabel('x'.repeat(228));
|
|
||||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
|
||||||
body: blob,
|
|
||||||
});
|
|
||||||
expect(put.status).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
|
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
|
||||||
const owner = await createChannel({ mode: 'raw' });
|
const owner = await createChannel({ mode: 'raw' });
|
||||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||||
@@ -448,7 +416,7 @@ suite('端到端(真实服务端)', () => {
|
|||||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||||
await joinChannel(member, 'read');
|
await joinChannel(member, 'read');
|
||||||
|
|
||||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...headersFor(member, 'read') } });
|
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(() => headersFor(member, 'owner')).toThrow();
|
expect(() => headersFor(member, 'owner')).toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { fromBase64Url, toBase64Url } from '../src/bytes.js';
|
|||||||
import {
|
import {
|
||||||
CapabilityError,
|
CapabilityError,
|
||||||
ChannelSession,
|
ChannelSession,
|
||||||
IntegrityError,
|
|
||||||
LimitError,
|
LimitError,
|
||||||
LinkError,
|
LinkError,
|
||||||
ModeMismatchError,
|
ModeMismatchError,
|
||||||
@@ -61,8 +60,8 @@ describe('创建 enc 频道', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('设置原样放进请求体', async () => {
|
it('设置原样放进请求体', async () => {
|
||||||
const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } });
|
const { request } = await ChannelSession.create({ settings: { max_members: 3, delete_policy: 'anyone' } });
|
||||||
expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' });
|
expect(request.body.settings).toEqual({ max_members: 3, delete_policy: 'anyone' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,39 +280,6 @@ describe('发送和接收(enc)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('设备名', () => {
|
|
||||||
it('所有者能读出成员的设备名,其他人换个终端哈希读不出', async () => {
|
|
||||||
const { session: owner } = await ChannelSession.create();
|
|
||||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
||||||
const blob = await member.sealLabel('办公室电脑');
|
|
||||||
|
|
||||||
expect(await owner.openLabel(blob, member.endpointHash)).toBe('办公室电脑');
|
|
||||||
expect(await owner.openLabel(toBase64Url(blob), toBase64Url(member.endpointHash))).toBe('办公室电脑');
|
|
||||||
await expect(owner.openLabel(blob, owner.endpointHash)).rejects.toThrowError(IntegrityError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('每次加密使用新的随机 IV', async () => {
|
|
||||||
const { session } = await ChannelSession.create();
|
|
||||||
const a = await session.sealLabel('x');
|
|
||||||
const b = await session.sealLabel('x');
|
|
||||||
expect(toHex(a.slice(0, 12))).not.toBe(toHex(b.slice(0, 12)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('篡改后解不开', async () => {
|
|
||||||
const { session } = await ChannelSession.create();
|
|
||||||
const blob = await session.sealLabel('abc');
|
|
||||||
blob[blob.length - 1]! ^= 1;
|
|
||||||
await expect(session.openLabel(blob, session.endpointHash)).rejects.toThrowError(IntegrityError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('raw 模式下设备名是明文', async () => {
|
|
||||||
const { session } = await ChannelSession.create({ mode: 'raw' });
|
|
||||||
const blob = await session.sealLabel('plain');
|
|
||||||
expect(new TextDecoder().decode(blob)).toBe('plain');
|
|
||||||
expect(await session.openLabel(blob, session.endpointHash)).toBe('plain');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('raw 模式往返', () => {
|
describe('raw 模式往返', () => {
|
||||||
it('单帧:保留统一元数据,没有加密标志', async () => {
|
it('单帧:保留统一元数据,没有加密标志', async () => {
|
||||||
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
|
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
|
||||||
|
|||||||
@@ -182,30 +182,6 @@ describe('raw 帧', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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('链接', () => {
|
describe('链接', () => {
|
||||||
it('合法链接解析结果与向量一致', () => {
|
it('合法链接解析结果与向量一致', () => {
|
||||||
for (const v of vectors.links.valid) {
|
for (const v of vectors.links.valid) {
|
||||||
|
|||||||
Reference in new issue
Block a user