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 { encodeEnvelope, sealFrame } from './frame.js'; import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js'; import { buildLink, parseLink, type ParsedLink } from './link.js'; import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js'; import { ItemReader, type ReaderContext } from './reader.js'; import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js'; export type AuthRole = 'read' | 'write' | 'owner'; /** 频道设置,字段名与 API 一致(snake_case)。 */ export interface ChannelSettings { seats?: number; members_can_upload?: boolean; delete_policy?: 'owner_only' | 'own' | 'anyone'; ttl_seconds?: number; max_items?: number; file_max_downloads?: number; ui_restrict_copy_download?: boolean; } /** POST /api/v1/channels 的请求体。 */ export interface CreateChannelBody { id: string; mode: ChannelMode; settings?: ChannelSettings; read_hash?: string; write_hash?: string; owner_hash: string; } export interface CreateOptions { mode?: ChannelMode; settings?: ChannelSettings; /** 本设备在这个频道里的终端密钥。由前端持久化(IndexedDB),下次打开链接时传回来;不传则新生成。 */ endpointSecret?: Uint8Array; } export interface OpenOptions { endpointSecret?: Uint8Array; } export interface CreatedChannel { session: ChannelSession; /** * 创建请求。服务端启用验证码时,还要在 headers 之外加上 `X-WD-Cap: ` * (token 由前端用 Cap widget 求解得到,见 GET /info 的 captcha.api_endpoint)。 */ request: { body: CreateChannelBody; headers: Record }; } export interface PlanItemOptions extends PlanOptions { /** 覆盖默认的时间戳(毫秒),主要用于测试。 */ ts?: number; /** 覆盖设备名。默认使用 session.deviceName。 */ dev?: string; /** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */ label?: string; } export interface FileMetaOverrides { name?: string; mime?: string; size?: number; } const ITEM_KEY_CACHE = 256; interface SessionInit { link: Pick; k?: Bytes; a?: Bytes; w?: Bytes; keys?: ChannelKeys; ownerToken?: Bytes; endpointSecret: Bytes; endpointHash: Bytes; } /** * 一个频道的会话:由链接中的密钥派生出一切,并提供加密、解密和请求头。 * * 密钥材料说明:AES 密钥(每条目、设备名)是不可导出的 CryptoKey,K_enc 导入后原始字节即清零。 * 但链接里的 K / A / W 必须保留在内存里(用来重新生成分享链接),令牌必须以字节形式放进请求头, * JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。 */ export class ChannelSession { readonly id: string; readonly idBytes: Bytes; readonly mode: ChannelMode; /** 来自链接:带管理密钥的是所有者,否则是成员。 */ readonly role: 'owner' | 'member'; /** 本终端的密钥 S(32 字节)。前端必须持久化它,否则每次打开都会占用新席位。 */ readonly endpointSecret: Bytes; /** SHA-256(S),即服务端看到的终端标识。 */ readonly endpointHash: Bytes; /** 加密条目元数据时默认带上的设备名。 */ deviceName: string | undefined; readonly #k: Bytes | undefined; readonly #a: Bytes | undefined; readonly #w: Bytes | undefined; readonly #keys: ChannelKeys | undefined; readonly #ownerToken: Bytes | undefined; readonly #itemKeys = new Map>(); private constructor(init: SessionInit) { this.id = init.link.id; this.idBytes = init.link.idBytes; this.mode = init.link.mode; this.role = init.link.role; this.#k = init.k; this.#a = init.a; this.#w = init.w; this.#keys = init.keys; this.#ownerToken = init.ownerToken; this.endpointSecret = init.endpointSecret; this.endpointHash = init.endpointHash; } // ───────────────────────── 创建与打开 ───────────────────────── /** 打开一个频道链接(含降级防御:链接里有 k 就是 enc,否则 raw)。 */ static async open(link: string | URL | ParsedLink, opts: OpenOptions = {}): Promise { const parsed = typeof link === 'string' || link instanceof URL ? parseLink(link) : link; const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE); if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节'); const init: SessionInit = { link: parsed, endpointSecret, endpointHash: await sha256(endpointSecret), }; if (parsed.k) { init.k = parsed.k; init.keys = await deriveChannelKeys(parsed.idBytes, parsed.k); } if (parsed.a) { init.a = parsed.a; init.ownerToken = await deriveOwnerToken(parsed.idBytes, parsed.a); } if (parsed.w) init.w = parsed.w; return new ChannelSession(init); } /** * 创建新频道:生成 id 和密钥,返回会话以及 POST /channels 的请求体和请求头。 * 随机数全部来自 crypto.getRandomValues。 */ static async create(opts: CreateOptions = {}): Promise { const mode = opts.mode ?? 'enc'; const idBytes = randomBytes(ID_SIZE); const id = toBase64Url(idBytes); const a = randomBytes(KEY_SIZE); const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE); if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节'); const ownerToken = await deriveOwnerToken(idBytes, a); const body: CreateChannelBody = { id, mode, owner_hash: toBase64Url(await sha256(ownerToken)) }; if (opts.settings) body.settings = opts.settings; const init: SessionInit = { link: { id, idBytes, mode, role: 'owner' as const }, a, ownerToken, endpointSecret, endpointHash: await sha256(endpointSecret), }; if (mode === 'enc') { const k = randomBytes(KEY_SIZE); const keys = await deriveChannelKeys(idBytes, k); init.k = k; init.keys = keys; body.read_hash = toBase64Url(await sha256(keys.readToken)); // enc 频道的写令牌总是由 K 派生;成员能不能写由频道设置 members_can_upload 决定 body.write_hash = toBase64Url(await sha256(keys.writeToken)); } else if (opts.settings?.members_can_upload !== false) { // raw 频道没有 K:成员的写令牌是随机的 W,放进成员链接 const w = randomBytes(KEY_SIZE); init.w = w; body.write_hash = toBase64Url(await sha256(w)); } const session = new ChannelSession(init); return { session, request: { body, headers: session.authHeaders('owner') } }; } // ───────────────────────── 凭据与链接 ───────────────────────── /** 当前会话能以哪些角色发请求。 */ can(role: AuthRole): boolean { switch (role) { case 'read': return true; // raw 无需令牌;enc 链接一定带 K case 'write': return this.mode === 'enc' ? this.#keys !== undefined : this.#w !== undefined; case 'owner': return this.#ownerToken !== undefined; } } /** X-WD-Role / Authorization / X-WD-Endpoint。缺少对应密钥时抛 CapabilityError。 */ authHeaders(role: AuthRole): Record { if (!this.can(role)) throw new CapabilityError(role); const headers: Record = { 'X-WD-Role': role, 'X-WD-Endpoint': toBase64Url(this.endpointSecret), }; const token = this.#token(role); if (token) headers.Authorization = `Bearer ${toBase64Url(token)}`; return headers; } #token(role: AuthRole): Bytes | undefined { if (role === 'owner') return this.#ownerToken; if (this.mode === 'enc') return role === 'read' ? this.#keys?.readToken : this.#keys?.writeToken; return role === 'write' ? this.#w : undefined; // raw 的读权限就是 id 本身 } /** * 核对服务端返回的频道信息(GET /channels/{id} 或创建响应)与链接是否一致。 * 链接带 k 却被告知是 raw,说明服务端想让客户端降级成明文,必须拒绝。 */ verifyChannelInfo(info: { id: string; mode: string }): void { if (info.id !== this.id) throw new LinkError('服务端返回了另一个频道'); if (info.mode !== this.mode) throw new ModeMismatchError(); } /** 生成分享链接。origin 如 "https://app.example.com"。 */ link(origin: string, kind: 'member' | 'owner' = 'member'): string { if (kind === 'owner') { if (!this.#a) throw new CapabilityError('owner'); return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), a: this.#a, ...(this.#w && { w: this.#w }) }); } return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), ...(this.mode === 'raw' && this.#w && { w: this.#w }) }); } // ───────────────────────── 发送:加密成帧 ───────────────────────── /** 一个条目的 AES-GCM 密钥,带缓存。 */ itemKey(itemId: Uint8Array): Promise { if (!this.#keys) throw new CapabilityError('enc'); const cacheKey = toBase64Url(itemId); let key = this.#itemKeys.get(cacheKey); if (!key) { key = deriveItemKey(this.#keys.encRoot, itemId); if (this.#itemKeys.size >= ITEM_KEY_CACHE) this.#itemKeys.delete(this.#itemKeys.keys().next().value as string); this.#itemKeys.set(cacheKey, key); } return key; } #meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial = {}): ItemMeta { const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra }; const dev = opts.dev ?? this.deviceName; if (dev !== undefined) meta.dev = dev; return meta; } #plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan { if (this.mode === 'enc') { return planEncItem( async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p), source, meta, opts, ); } // raw:没有元数据,内容原样放进负载 return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts); } encryptBytes(content: Uint8Array, meta: Partial & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan { const { kind, ...rest } = meta; return this.#plan(content, this.#meta(kind ?? 'other', opts, rest), opts); } encryptText(text: string, opts: PlanItemOptions = {}): ItemPlan { return this.#plan(utf8(text), this.#meta('text', opts), opts); } /** 把一个 JSON 值作为条目发送。`snapshot: true` 时作为频道的最新快照。 */ encryptJson(value: unknown, opts: PlanItemOptions = {}): ItemPlan { const text = JSON.stringify(value); if (text === undefined) throw new TypeError('值无法序列化为 JSON'); return this.#plan(utf8(text), this.#meta('json', opts), opts); } /** * 发送文件或流。File/Blob 会自动带上文件名、类型和大小;ReadableStream 的大小未知, * 可通过 meta 覆盖。放不进单帧的内容自动拆成多帧并标记 GATED。 */ encryptFile(file: Blob | ReadableStream, meta: FileMetaOverrides = {}, opts: PlanItemOptions = {}): ItemPlan { const extra: Partial = {}; const isBlob = typeof Blob !== 'undefined' && file instanceof Blob; const name = meta.name ?? (isBlob && 'name' in file ? (file as File).name : undefined); const mime = meta.mime ?? (isBlob && file.type ? file.type : undefined); const size = meta.size ?? (isBlob ? file.size : undefined); if (name !== undefined) extra.name = name; if (mime !== undefined) extra.mime = mime; if (size !== undefined) extra.size = size; return this.#plan(file, this.#meta('file', opts, extra), opts); } // ───────────────────────── 接收:解密与装配 ───────────────────────── get #readerContext(): ReaderContext { return { channelId: this.idBytes, mode: this.mode, itemKey: (itemId) => this.itemKey(itemId), }; } /** 创建一个条目装配器:喂入 WebSocket / log 接口的记录,得到解密后的条目事件。 */ createItemReader(): ItemReader { return new ItemReader(this.#readerContext); } /** 流式解密一个条目(GET /items/{id}/frames 的响应体),用于大文件。 */ decryptItemStream( source: ReadableStream | AsyncIterable, opts: DecryptStreamOptions = {}, ): { content: ReadableStream; result: Promise } { return decryptItemStream(this.#readerContext, source, opts); } // ───────────────────────── 设备名(protocol §10) ───────────────────────── /** * 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16), * AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。 * @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。 */ async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise { 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(this.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 无法保证擦除,这只是缩小暴露窗口。 */ dispose(): void { for (const b of [this.#k, this.#a, this.#w, this.#ownerToken, this.#keys?.readToken, this.#keys?.writeToken]) { if (b) wipe(b); } wipe(this.endpointSecret); this.#itemKeys.clear(); } } /** 打开频道链接的便捷函数。 */ export const openLink = ChannelSession.open;