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

402 lines
17 KiB
TypeScript

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: <Cap 的 token>`
* (token 由前端用 Cap widget 求解得到,见 GET /info 的 captcha.api_endpoint)。
*/
request: { body: CreateChannelBody; headers: Record<string, string> };
}
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<ParsedLink, 'id' | 'idBytes' | 'mode' | 'role'>;
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<string, Promise<CryptoKey>>();
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<ChannelSession> {
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<CreatedChannel> {
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<string, string> {
if (!this.can(role)) throw new CapabilityError(role);
const headers: Record<string, string> = {
'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<CryptoKey> {
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> = {}): 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<ItemMeta> & { 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<Uint8Array>, meta: FileMetaOverrides = {}, opts: PlanItemOptions = {}): ItemPlan {
const extra: Partial<ItemMeta> = {};
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<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
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<Bytes> {
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<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 无法保证擦除,这只是缩小暴露窗口。 */
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;