From 5fd4ddd411988b2748e5d99755a9ce66617f3104 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 14 Oct 2026 22:19:24 +0800 Subject: [PATCH] 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. --- packages/core/README.md | 11 ++--- packages/core/src/constants.ts | 4 -- packages/core/src/session.ts | 70 ++---------------------------- packages/core/test/e2e.test.ts | 36 +-------------- packages/core/test/session.test.ts | 38 +--------------- packages/core/test/vectors.test.ts | 24 ---------- 6 files changed, 12 insertions(+), 171 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 17c051f..2b7956a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,7 +51,6 @@ ws.onmessage = async (m) => { | 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 | | 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 | | 链接生成与解析、降级防御 | 持久化链接和**终端密钥** | -| 设备名加解密 | 复制、下载(把流写到文件)、预览 | | 日志记录解析 | **Cap 验证码**(见下文) | ## API @@ -67,7 +66,6 @@ ws.onmessage = async (m) => { | `session.link(origin, 'member' \| 'owner')` | 生成分享链接 | | `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` | | `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 | -| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 | | `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) | ### 发送 @@ -91,7 +89,7 @@ ws.onmessage = async (m) => { | `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED | | `itemId` | 重试和续传必须复用同一个 id | | `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` | -| `ts`, `dev` | 覆盖时间戳和设备名 | +| `ts` | 覆盖时间戳 | **GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。 @@ -131,13 +129,12 @@ const { complete, nextIndex, meta } = await result; | 调用 | 说明 | |---|---| | `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 | -| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) | | `parseLink(url)` / `buildLink(origin, parts)` | 链接 | | `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 | ## 必须注意的几点 -**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它登记独立设备席位以便认证和撤销;频道成员上限按账号数计算,同一账号的多台设备不重复占名额。不持久化的话,每次打开链接都会登记一个新设备终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 +**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { 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 个负例 | -| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 | +| `session.test.ts` | 创建、链接、角色、降级防御、收发、raw 模式、条目计划 | | `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 | | `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 | | `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 | -| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 | +| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传 | 另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。 diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index fe76e99..a955f74 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -38,11 +38,7 @@ export const Labels = { /** AAD 前缀。 */ 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; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 3dc38ee..ace17af 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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 { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js'; -import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js'; +import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js'; +import { randomBytes, toBase64Url, utf8, wipe, type Bytes } from './bytes.js'; +import { CapabilityError, LinkError, ModeMismatchError } from './errors.js'; import { encodeEnvelope, sealFrame } from './frame.js'; import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js'; import { buildLink, parseLink, type ParsedLink } from './link.js'; @@ -15,7 +15,7 @@ export interface ChannelSettings { join_policy?: 'open' | 'invite'; allow_guests?: boolean; name?: string; - seats?: number; + max_members?: number; members_can_upload?: boolean; delete_policy?: 'owner_only' | 'own' | 'anyone'; ttl_seconds?: number; @@ -331,68 +331,6 @@ export class ChannelSession { 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 { - 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 { - 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 无法保证擦除,这只是缩小暴露窗口。 */ diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts index 9b03a50..eaa70a2 100644 --- a/packages/core/test/e2e.test.ts +++ b/packages/core/test/e2e.test.ts @@ -114,7 +114,7 @@ async function createChannel(opts: Parameters[0] = } 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); } @@ -390,38 +390,6 @@ suite('端到端(真实服务端)', () => { expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0); }, 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 () => { const owner = await createChannel({ mode: 'raw' }); const member = await ChannelSession.open(owner.link(ORIGIN)); @@ -448,7 +416,7 @@ suite('端到端(真实服务端)', () => { const member = await ChannelSession.open(owner.link(ORIGIN)); 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(() => headersFor(member, 'owner')).toThrow(); }); diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index d23c9ee..d97659f 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -3,7 +3,6 @@ import { fromBase64Url, toBase64Url } from '../src/bytes.js'; import { CapabilityError, ChannelSession, - IntegrityError, LimitError, LinkError, ModeMismatchError, @@ -61,8 +60,8 @@ describe('创建 enc 频道', () => { }); it('设置原样放进请求体', async () => { - const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } }); - expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' }); + const { request } = await ChannelSession.create({ settings: { max_members: 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 模式往返', () => { it('单帧:保留统一元数据,没有加密标志', async () => { const { session: owner } = await ChannelSession.create({ mode: 'raw' }); diff --git a/packages/core/test/vectors.test.ts b/packages/core/test/vectors.test.ts index dd5f8c7..0a40eee 100644 --- a/packages/core/test/vectors.test.ts +++ b/packages/core/test/vectors.test.ts @@ -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('链接', () => { it('合法链接解析结果与向量一致', () => { for (const v of vectors.links.valid) {