chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 字节工具。只用标准 JS,不依赖 Node 或 DOM 之外的任何东西。
|
||||
*
|
||||
* WebCrypto 的类型(TS 5.7+)要求 BufferSource 由 ArrayBuffer 支撑,
|
||||
* 所以库内部统一使用 Bytes = Uint8Array<ArrayBuffer>。
|
||||
*/
|
||||
|
||||
export type Bytes = Uint8Array<ArrayBuffer>;
|
||||
|
||||
/** 确保由 ArrayBuffer 支撑(SharedArrayBuffer 或其他情况下复制一份)。 */
|
||||
export function toBytes(u: Uint8Array): Bytes {
|
||||
return u.buffer instanceof ArrayBuffer ? (u as Bytes) : new Uint8Array(u);
|
||||
}
|
||||
|
||||
export function concat(...parts: Uint8Array[]): Bytes {
|
||||
let total = 0;
|
||||
for (const p of parts) total += p.length;
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function readU16(b: Uint8Array, offset: number): number {
|
||||
return ((b[offset] as number) << 8) | (b[offset + 1] as number);
|
||||
}
|
||||
|
||||
export function readU32(b: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(((b[offset] as number) << 24) |
|
||||
((b[offset + 1] as number) << 16) |
|
||||
((b[offset + 2] as number) << 8) |
|
||||
(b[offset + 3] as number)) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** 读 u64,超过 2^53 - 1 时抛错(seq 和时间戳实际远小于这个值)。 */
|
||||
export function readU64(b: Uint8Array, offset: number): number {
|
||||
const hi = readU32(b, offset);
|
||||
const lo = readU32(b, offset + 4);
|
||||
if (hi > 0x1fffff) throw new RangeError('u64 超出 JS 安全整数范围');
|
||||
return hi * 0x1_0000_0000 + lo;
|
||||
}
|
||||
|
||||
export function writeU16(value: number): Bytes {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new RangeError('u16 超出范围');
|
||||
return new Uint8Array([value >>> 8, value & 0xff]);
|
||||
}
|
||||
|
||||
export function writeU32(value: number): Bytes {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) throw new RangeError('u32 超出范围');
|
||||
return new Uint8Array([value >>> 24, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]);
|
||||
}
|
||||
|
||||
export function writeU64(value: number): Bytes {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('u64 超出范围');
|
||||
const out = new Uint8Array(8);
|
||||
const hi = Math.floor(value / 0x1_0000_0000);
|
||||
const lo = value >>> 0;
|
||||
out.set(writeU32(hi), 0);
|
||||
out.set(writeU32(lo), 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── hex ─────────
|
||||
|
||||
export function toHex(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const x of b) s += x.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function fromHex(s: string): Bytes {
|
||||
if (s.length % 2 !== 0 || /[^0-9a-fA-F]/.test(s)) throw new RangeError('非法的十六进制字符串');
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── base64url(无填充,严格规范) ─────────
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
const LOOKUP = (() => {
|
||||
const t = new Int8Array(128).fill(-1);
|
||||
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET.charCodeAt(i)] = i;
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function toBase64Url(b: Uint8Array): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
for (; i + 2 < b.length; i += 3) {
|
||||
const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8) | (b[i + 2] as number);
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]! + ALPHABET[n & 63]!;
|
||||
}
|
||||
const rest = b.length - i;
|
||||
if (rest === 1) {
|
||||
const n = (b[i] as number) << 16;
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]!;
|
||||
} else if (rest === 2) {
|
||||
const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8);
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]!;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格解码:只接受 url 字母表,拒绝填充和非规范编码(末尾多余的比特必须为 0),
|
||||
* 与服务端 Codec.TryDecodeExact 的行为一致。非法输入返回 null,绝不抛异常。
|
||||
*/
|
||||
export function fromBase64Url(s: string, expectedLength?: number): Bytes | null {
|
||||
const len = s.length;
|
||||
if (len % 4 === 1) return null;
|
||||
const outLen = Math.floor((len * 3) / 4);
|
||||
if (expectedLength !== undefined && outLen !== expectedLength) return null;
|
||||
|
||||
const out = new Uint8Array(outLen);
|
||||
let o = 0;
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
const v = code < 128 ? (LOOKUP[code] as number) : -1;
|
||||
if (v < 0) return null;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[o++] = (acc >>> bits) & 0xff;
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
if (acc !== 0) return null; // 末尾多余比特必须为 0
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── 文本 ─────────
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export function utf8(s: string): Bytes {
|
||||
return encoder.encode(s) as Bytes;
|
||||
}
|
||||
|
||||
/** 严格的 UTF-8 解码,非法序列返回 null。 */
|
||||
export function tryDecodeUtf8(b: Uint8Array): string | null {
|
||||
try {
|
||||
return strictDecoder.decode(b);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function randomBytes(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 尽力清零。JS 无法保证内存被真正擦除(GC 可能已复制过),这只是缩小暴露窗口。 */
|
||||
export function wipe(b: Uint8Array): void {
|
||||
b.fill(0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** 协议常量,与 docs/protocol.md 一一对应。 */
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export const ID_SIZE = 16;
|
||||
export const KEY_SIZE = 32;
|
||||
export const TAG_SIZE = 16;
|
||||
export const HEADER_SIZE = 26;
|
||||
|
||||
/** 帧标志(protocol §4)。 */
|
||||
export const Flags = {
|
||||
Final: 1,
|
||||
Snapshot: 2,
|
||||
Gated: 4,
|
||||
Enc: 8,
|
||||
} as const;
|
||||
|
||||
/** 档位 padding 的明文长度(protocol §6)。 */
|
||||
export const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576] as const;
|
||||
|
||||
/** 单帧明文上限 1 MiB,含 4 字节长度前缀,所以 data 最多 1,048,572 字节。 */
|
||||
export const MAX_FRAME_PLAINTEXT = 1048576;
|
||||
export const MAX_FRAME_DATA = MAX_FRAME_PLAINTEXT - 4;
|
||||
|
||||
/** raw 模式的负载没有加密开销,单帧上限取 1 MiB。 */
|
||||
export const RAW_MAX_PAYLOAD = 1048576;
|
||||
|
||||
/** HKDF info 标签(protocol §3)。 */
|
||||
export const Labels = {
|
||||
enc: 'webdrop/v1/enc',
|
||||
read: 'webdrop/v1/token/read',
|
||||
write: 'webdrop/v1/token/write',
|
||||
owner: 'webdrop/v1/token/owner',
|
||||
item: 'webdrop/v1/item',
|
||||
label: 'webdrop/v1/label',
|
||||
} as const;
|
||||
|
||||
/** 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;
|
||||
|
||||
export type ChannelMode = 'enc' | 'raw';
|
||||
export type ItemKind = 'text' | 'json' | 'file' | 'other';
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 错误类型。消息里绝不包含密钥、令牌或明文。
|
||||
*/
|
||||
|
||||
export class WebDropError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 接收方校验失败的原因,与 test-vectors 的 expect 字段对应。 */
|
||||
export type IntegrityReason =
|
||||
| 'auth_failed' // GCM 认证失败:内容被篡改、头部被改、换了频道或条目
|
||||
| 'bad_envelope' // 封装格式不合法
|
||||
| 'wrong_mode' // enc 会话收到没有 ENC 标志的帧,或 raw 会话收到 ENC 帧
|
||||
| 'bad_padding' // 明文长度字段或填充不合法
|
||||
| 'bad_meta' // 条目元数据不合法
|
||||
| 'gap' // 帧序号不连续
|
||||
| 'item_mismatch' // 同一个流里混入了别的条目的帧
|
||||
| 'flags_changed' // 同一条目内 SNAPSHOT / GATED 标志不一致
|
||||
| 'duplicate_mismatch' // 同一 (条目, 序号) 出现了不同内容
|
||||
| 'frame_after_final' // FINAL 之后又出现了帧
|
||||
| 'final_conflict' // FINAL 的位置前后矛盾
|
||||
| 'incomplete'; // 流结束时条目还没有已认证的 FINAL
|
||||
|
||||
/** 数据被篡改或不符合协议。接收方应丢弃整个条目。 */
|
||||
export class IntegrityError extends WebDropError {
|
||||
readonly reason: IntegrityReason;
|
||||
|
||||
constructor(reason: IntegrityReason) {
|
||||
super('integrity', `完整性校验失败: ${reason}`);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkError extends WebDropError {
|
||||
constructor(message: string) {
|
||||
super('link', message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务端报告的频道模式与链接不符(降级攻击或链接损坏)。 */
|
||||
export class ModeMismatchError extends WebDropError {
|
||||
constructor() {
|
||||
super('mode_mismatch', '频道模式与链接不一致,已拒绝');
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前会话没有这个能力(例如成员链接没有所有者令牌)。 */
|
||||
export class CapabilityError extends WebDropError {
|
||||
constructor(what: string) {
|
||||
super('capability', `当前链接不具备 ${what} 权限`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LimitError extends WebDropError {
|
||||
constructor(message: string) {
|
||||
super('limit', message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AAD_FRAME, Flags, HEADER_SIZE, ID_SIZE, MAX_FRAME_DATA, MAX_FRAME_PLAINTEXT, TAG_SIZE, TIERS } from './constants.js';
|
||||
import { concat, readU32, utf8, writeU32, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
|
||||
// ───────────────────────── padding(protocol §6) ─────────────────────────
|
||||
|
||||
/** 不小于 total 的最小档位。 */
|
||||
export function tierFor(total: number): number {
|
||||
for (const t of TIERS) if (t >= total) return t;
|
||||
throw new RangeError('超出单帧上限');
|
||||
}
|
||||
|
||||
/** 明文 = u32 data_len ‖ data ‖ 零填充,总长为档位。 */
|
||||
export function padPlaintext(data: Uint8Array): Bytes {
|
||||
if (data.length > MAX_FRAME_DATA) throw new RangeError('单帧 data 最多 1,048,572 字节');
|
||||
const tier = tierFor(4 + data.length);
|
||||
const out = new Uint8Array(tier);
|
||||
out.set(writeU32(data.length), 0);
|
||||
out.set(data, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 接收方校验:长度字段不越界,总长是档位,填充全为零。 */
|
||||
export function unpadPlaintext(plain: Uint8Array): Bytes {
|
||||
if (plain.length < 4 || !(TIERS as readonly number[]).includes(plain.length)) throw new IntegrityError('bad_padding');
|
||||
const len = readU32(plain, 0);
|
||||
if (len > plain.length - 4) throw new IntegrityError('bad_padding');
|
||||
for (let i = 4 + len; i < plain.length; i++) {
|
||||
if (plain[i] !== 0) throw new IntegrityError('bad_padding');
|
||||
}
|
||||
return plain.slice(4, 4 + len);
|
||||
}
|
||||
|
||||
// ───────────────────────── 封装(protocol §4) ─────────────────────────
|
||||
|
||||
export interface Envelope {
|
||||
flags: number;
|
||||
itemId: Bytes;
|
||||
frameIndex: number;
|
||||
payloadLength: number;
|
||||
/** 前 26 字节。 */
|
||||
header: Bytes;
|
||||
payload: Bytes;
|
||||
/** 完整封装。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
/** 严格解析:版本、保留位、长度必须全部合法。与服务端 Envelope.TryParse 一致。 */
|
||||
export function parseEnvelope(data: Uint8Array): Envelope {
|
||||
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const flags = data[1] as number;
|
||||
if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(data, 22);
|
||||
if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope');
|
||||
|
||||
const bytes = data.slice();
|
||||
return {
|
||||
flags,
|
||||
itemId: bytes.slice(2, 2 + ID_SIZE),
|
||||
frameIndex: readU32(bytes, 18),
|
||||
payloadLength,
|
||||
header: bytes.slice(0, HEADER_SIZE),
|
||||
payload: bytes.slice(HEADER_SIZE),
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvelope(flags: number, itemId: Uint8Array, frameIndex: number, payload: Uint8Array): Bytes {
|
||||
if (itemId.length !== ID_SIZE) throw new RangeError('item_id 必须是 16 字节');
|
||||
const header = concat(new Uint8Array([1, flags]), itemId, writeU32(frameIndex), writeU32(payload.length));
|
||||
return concat(header, payload);
|
||||
}
|
||||
|
||||
/** 流式解析时用:从缓冲区起点读出下一个封装的总长度,数据不够返回 null。 */
|
||||
export function peekEnvelopeLength(buf: Uint8Array, maxPayload: number): number | null {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
if (buf[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(buf, 22);
|
||||
if (payloadLength > maxPayload) throw new IntegrityError('bad_envelope');
|
||||
return HEADER_SIZE + payloadLength;
|
||||
}
|
||||
|
||||
export const MAX_ENC_PAYLOAD = MAX_FRAME_PLAINTEXT + TAG_SIZE;
|
||||
|
||||
// ───────────────────────── 加解密(protocol §5) ─────────────────────────
|
||||
|
||||
/** IV = 8 字节零 ‖ frame_index(u32)。每个条目有独立的密钥,所以 frame_index 唯一即保证 IV 唯一。 */
|
||||
export function frameIv(frameIndex: number): Bytes {
|
||||
const iv = new Uint8Array(12);
|
||||
iv.set(writeU32(frameIndex), 8);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/** AAD = "WD1" ‖ channel_id ‖ envelope[0..22),绑定频道、条目、序号和全部标志位。 */
|
||||
export function frameAad(channelId: Uint8Array, header: Uint8Array): Bytes {
|
||||
return concat(utf8(AAD_FRAME), channelId, header.subarray(0, 22));
|
||||
}
|
||||
|
||||
export interface SealParams {
|
||||
flags: number;
|
||||
itemId: Uint8Array;
|
||||
index: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一帧并返回完整封装。
|
||||
* 注意:同一 (itemId, index) 只能加密一份固定明文,否则会造成 IV 重用。重试必须重发相同字节。
|
||||
*/
|
||||
export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> {
|
||||
if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志');
|
||||
if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0');
|
||||
|
||||
const plaintext = padPlaintext(p.data);
|
||||
const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE));
|
||||
|
||||
const cipher = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(p.index), additionalData: frameAad(channelId, header), tagLength: 128 },
|
||||
itemKey,
|
||||
plaintext,
|
||||
);
|
||||
return concat(header, new Uint8Array(cipher));
|
||||
}
|
||||
|
||||
/** 认证并解密一帧,返回去掉 padding 的 data。任何失败都抛 IntegrityError。 */
|
||||
export async function openFrame(itemKey: CryptoKey, channelId: Uint8Array, env: Envelope): Promise<Bytes> {
|
||||
if ((env.flags & Flags.Enc) === 0) throw new IntegrityError('wrong_mode');
|
||||
if (env.payloadLength < TAG_SIZE) throw new IntegrityError('bad_envelope');
|
||||
|
||||
let plain: Bytes;
|
||||
try {
|
||||
plain = new Uint8Array(
|
||||
await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(env.frameIndex), additionalData: frameAad(channelId, env.header), tagLength: 128 },
|
||||
itemKey,
|
||||
env.payload,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
throw new IntegrityError('auth_failed');
|
||||
}
|
||||
return unpadPlaintext(plain);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @webdrop/core:WebDrop 的端到端加密核心。
|
||||
*
|
||||
* 前端只需要这几件事:
|
||||
* ChannelSession.create(...) 创建频道,得到会话、POST /channels 的请求体和请求头、分享链接
|
||||
* ChannelSession.open(link) 打开链接,得到会话
|
||||
* session.authHeaders(role) 所有请求要带的头
|
||||
* session.encryptText/Json/File 把内容加密成可直接 POST 的帧
|
||||
* session.createItemReader() 喂入 WebSocket / log 的记录,得到解密后的条目
|
||||
* session.decryptItemStream() 流式解密大文件
|
||||
*
|
||||
* 本包只依赖 WebCrypto,不含 UI,不发网络请求,不处理重连和队列。
|
||||
*/
|
||||
|
||||
export { ChannelSession, openLink } from './session.js';
|
||||
export type {
|
||||
AuthRole,
|
||||
ChannelSettings,
|
||||
CreateChannelBody,
|
||||
CreatedChannel,
|
||||
CreateOptions,
|
||||
FileMetaOverrides,
|
||||
OpenOptions,
|
||||
PlanItemOptions,
|
||||
} from './session.js';
|
||||
|
||||
export { collectFrames } from './item.js';
|
||||
export type { ContentSource, EncodedFrame, ItemMeta, ItemPlan, PlanOptions } from './item.js';
|
||||
|
||||
export { ItemReader, itemJson, itemText } from './reader.js';
|
||||
export type { ItemEvent, ReaderContext, ReceivedItem, ReceivedKind } from './reader.js';
|
||||
|
||||
export { decryptItemFrames, decryptItemStream, splitEnvelopes } from './stream.js';
|
||||
export type { DecryptStreamOptions, ItemDecodeResult } from './stream.js';
|
||||
|
||||
export { parseLogRecord, parseLogRecords } from './log.js';
|
||||
export type { LogRecord, RemovedReason } from './log.js';
|
||||
|
||||
export { buildLink, parseLink } from './link.js';
|
||||
export type { LinkParts, ParsedLink } from './link.js';
|
||||
|
||||
export { fromBase64Url, toBase64Url } from './bytes.js';
|
||||
|
||||
export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js';
|
||||
export type { ChannelMode, ItemKind } from './constants.js';
|
||||
|
||||
export {
|
||||
CapabilityError,
|
||||
IntegrityError,
|
||||
LimitError,
|
||||
LinkError,
|
||||
ModeMismatchError,
|
||||
WebDropError,
|
||||
} from './errors.js';
|
||||
export type { IntegrityReason } from './errors.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 仅供测试和调试:把通常不可导出的中间密钥以字节形式导出,用来和测试向量逐字节比对。
|
||||
* 正常使用请只依赖主入口。这里的函数会让密钥材料暴露在 JS 变量里,不要在产品代码中使用。
|
||||
*/
|
||||
|
||||
import { Labels } from './constants.js';
|
||||
import { utf8, type Bytes } from './bytes.js';
|
||||
|
||||
export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js';
|
||||
export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js';
|
||||
export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js';
|
||||
|
||||
async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> {
|
||||
const key = await globalThis.crypto.subtle.importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await globalThis.crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) },
|
||||
key,
|
||||
256,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export interface RawChannelSecrets {
|
||||
kEnc: Bytes;
|
||||
tRead: Bytes;
|
||||
tWrite: Bytes;
|
||||
kLabel: Bytes;
|
||||
}
|
||||
|
||||
export async function deriveRawChannelSecrets(channelId: Uint8Array, k: Uint8Array): Promise<RawChannelSecrets> {
|
||||
const kEnc = await hkdfBytes(k, channelId, Labels.enc);
|
||||
return {
|
||||
kEnc,
|
||||
tRead: await hkdfBytes(k, channelId, Labels.read),
|
||||
tWrite: await hkdfBytes(k, channelId, Labels.write),
|
||||
kLabel: await hkdfBytes(kEnc, channelId, Labels.label),
|
||||
};
|
||||
}
|
||||
|
||||
export const deriveRawOwnerToken = (channelId: Uint8Array, a: Uint8Array): Promise<Bytes> => hkdfBytes(a, channelId, Labels.owner);
|
||||
|
||||
export const deriveRawItemKey = (kEnc: Uint8Array, itemId: Uint8Array): Promise<Bytes> => hkdfBytes(kEnc, itemId, Labels.item);
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
|
||||
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, LimitError } from './errors.js';
|
||||
|
||||
// ───────────────────────── 条目元数据(protocol §7) ─────────────────────────
|
||||
|
||||
export interface ItemMeta {
|
||||
kind: ItemKind;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
/** 内容总字节数,流式上传时可能未知。 */
|
||||
size?: number;
|
||||
/** 毫秒时间戳,仅供参考,排序以 seq 为准。 */
|
||||
ts: number;
|
||||
/** 设备名。 */
|
||||
dev?: string;
|
||||
}
|
||||
|
||||
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'other'];
|
||||
|
||||
/** u16 meta_len ‖ meta_json。字段顺序固定,undefined 的字段省略,保证字节级确定。 */
|
||||
export function encodeMeta(meta: ItemMeta): Bytes {
|
||||
const o: Record<string, unknown> = { v: 1, kind: meta.kind };
|
||||
if (meta.name !== undefined) o.name = meta.name;
|
||||
if (meta.mime !== undefined) o.mime = meta.mime;
|
||||
if (meta.size !== undefined) o.size = meta.size;
|
||||
o.ts = meta.ts;
|
||||
if (meta.dev !== undefined) o.dev = meta.dev;
|
||||
|
||||
const json = utf8(JSON.stringify(o));
|
||||
if (json.length > 0xffff) throw new LimitError('条目元数据过大');
|
||||
return concat(writeU16(json.length), json);
|
||||
}
|
||||
|
||||
/** 解析第 0 帧的 data:u16 meta_len ‖ meta_json ‖ 内容。 */
|
||||
export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: Bytes } {
|
||||
if (data.length < 2) throw new IntegrityError('bad_meta');
|
||||
const metaLen = readU16(data, 0);
|
||||
if (data.length < 2 + metaLen) throw new IntegrityError('bad_meta');
|
||||
|
||||
const text = tryDecodeUtf8(data.subarray(2, 2 + metaLen));
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new IntegrityError('bad_meta');
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
if (o.v !== 1 || typeof o.kind !== 'string' || typeof o.ts !== 'number' || !Number.isFinite(o.ts)) {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
const meta: ItemMeta = {
|
||||
// 未知的 kind 按 other 处理,向前兼容
|
||||
kind: (KINDS as readonly string[]).includes(o.kind) ? (o.kind as ItemKind) : 'other',
|
||||
ts: o.ts,
|
||||
};
|
||||
for (const key of ['name', 'mime', 'dev'] as const) {
|
||||
const v = o[key];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v !== 'string') throw new IntegrityError('bad_meta');
|
||||
meta[key] = v;
|
||||
}
|
||||
if (o.size !== undefined) {
|
||||
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
|
||||
meta.size = o.size;
|
||||
}
|
||||
return { meta, content: data.slice(2 + metaLen) };
|
||||
}
|
||||
|
||||
// ───────────────────────── 条目编码(加密) ─────────────────────────
|
||||
|
||||
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||
|
||||
export interface PlanOptions {
|
||||
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
|
||||
itemId?: Uint8Array;
|
||||
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
|
||||
snapshot?: boolean;
|
||||
/**
|
||||
* 是否 GATED(不实时内联推送,只能按条目拉取并计下载次数)。
|
||||
* 'auto'(默认):内容放不进一帧,或设置了 maxDownloads 时为 GATED。
|
||||
*/
|
||||
gated?: boolean | 'auto';
|
||||
/** 下载次数上限,0 为不限。设置后条目必须是 GATED;需要作为 X-WD-Max-Downloads 随第 0 帧发送。 */
|
||||
maxDownloads?: number;
|
||||
/** 单帧 data 的字节上限,默认 1,048,572。调小只用于测试和极弱网络。 */
|
||||
maxFrameData?: number;
|
||||
/** 从第几帧开始产出(断点续传)。只支持长度已知的来源(Uint8Array、Blob)。 */
|
||||
fromFrame?: number;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
index: number;
|
||||
final: boolean;
|
||||
/** 完整封装,可直接作为 POST /frames 的请求体。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
export interface ItemPlan {
|
||||
/** 条目 id 的 base64url 文本。 */
|
||||
itemId: string;
|
||||
idBytes: Bytes;
|
||||
/**
|
||||
* 是否 GATED。来源长度已知时在创建计划时就确定;来源是流(长度未知)时,
|
||||
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
|
||||
*/
|
||||
readonly gated: boolean;
|
||||
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
|
||||
frame0Headers: Record<string, string>;
|
||||
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
|
||||
frames: AsyncGenerator<EncodedFrame, void, void>;
|
||||
}
|
||||
|
||||
export async function collectFrames(plan: ItemPlan): Promise<EncodedFrame[]> {
|
||||
const out: EncodedFrame[] = [];
|
||||
for await (const f of plan.frames) out.push(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newItemId(): Bytes {
|
||||
const id = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 把各种来源统一成字节块的异步序列。 */
|
||||
async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void, void> {
|
||||
if (source instanceof Uint8Array) {
|
||||
yield source;
|
||||
return;
|
||||
}
|
||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||
yield* chunksOf(source.stream());
|
||||
return;
|
||||
}
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
|
||||
const reader = source.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
yield* source as AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
data: Bytes;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意切分的字节流重新切成 [firstCap, cap, cap, ...] 大小的块,并通过向前看一个块知道哪一块是最后一块。
|
||||
* 内容恰好填满某一块时不会多出一个空块;内容为空时产出一个空的最后一块。
|
||||
*/
|
||||
async function* rechunk(source: ContentSource, firstCap: number, cap: number): AsyncGenerator<Chunk, void, void> {
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let target = firstCap;
|
||||
let ended = false;
|
||||
const iter = chunksOf(source)[Symbol.asyncIterator]();
|
||||
|
||||
const take = (n: number): Bytes => {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
while (filled < n) {
|
||||
const head = parts[0] as Uint8Array;
|
||||
const need = n - filled;
|
||||
if (head.length <= need) {
|
||||
out.set(head, filled);
|
||||
filled += head.length;
|
||||
parts.shift();
|
||||
} else {
|
||||
out.set(head.subarray(0, need), filled);
|
||||
parts[0] = head.subarray(need);
|
||||
filled += need;
|
||||
}
|
||||
}
|
||||
total -= n;
|
||||
return out;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
// 需要缓冲到 target+1 字节,才能确定当前块不是最后一块
|
||||
while (!ended && total <= target) {
|
||||
const r = await iter.next();
|
||||
if (r.done) ended = true;
|
||||
else if (r.value.length > 0) {
|
||||
parts.push(r.value);
|
||||
total += r.value.length;
|
||||
}
|
||||
}
|
||||
if (total > target) {
|
||||
yield { data: take(target), last: false };
|
||||
target = cap;
|
||||
continue;
|
||||
}
|
||||
yield { data: take(total), last: true };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isSized(source: ContentSource): source is Uint8Array | Blob {
|
||||
return source instanceof Uint8Array || (typeof Blob !== 'undefined' && source instanceof Blob);
|
||||
}
|
||||
|
||||
/** 构造条目计划的公共部分:标志位、GATED 的取舍、校验。 */
|
||||
function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined): boolean {
|
||||
const wantsLimit = opts.maxDownloads !== undefined;
|
||||
if (opts.snapshot && (opts.gated === true || wantsLimit)) throw new RangeError('快照不能是 GATED,也不能设置下载上限');
|
||||
if (opts.gated === true || wantsLimit) return true;
|
||||
if (opts.gated === false) {
|
||||
if (fitsInOneFrame === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
return false;
|
||||
}
|
||||
return fitsInOneFrame === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* enc 模式:把内容加密成一个条目(protocol §7)。
|
||||
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
|
||||
*/
|
||||
export function planEncItem(
|
||||
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
|
||||
source: ContentSource,
|
||||
meta: ItemMeta,
|
||||
opts: PlanOptions = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
|
||||
if (maxData < 1 || maxData > MAX_FRAME_DATA) throw new RangeError('maxFrameData 超出范围');
|
||||
|
||||
const head = encodeMeta(meta);
|
||||
const cap0 = maxData - head.length;
|
||||
if (cap0 < 0) throw new LimitError('元数据超过单帧上限');
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
const fits = size === undefined ? undefined : size <= cap0;
|
||||
let gated = resolveGating(opts, fits);
|
||||
|
||||
const fromFrame = opts.fromFrame ?? 0;
|
||||
if (fromFrame > 0 && !isSized(source)) throw new RangeError('只有长度已知的来源才能从中间帧续传');
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc;
|
||||
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
|
||||
index,
|
||||
final: last,
|
||||
bytes: await seal({ flags: base(gatedNow) | (last ? Flags.Final : 0), itemId: idBytes, index, data }),
|
||||
});
|
||||
|
||||
if (fromFrame > 0) {
|
||||
// 断点续传:跳过已经上传的帧。第 k(>=1)帧的内容起点 = cap0 + (k-1) * maxData
|
||||
const blob = source as Uint8Array | Blob;
|
||||
const total = blob instanceof Uint8Array ? blob.length : blob.size;
|
||||
if (total <= cap0) throw new RangeError('内容只有一帧,没有可续传的后续帧');
|
||||
const offset = cap0 + (fromFrame - 1) * maxData;
|
||||
if (offset >= total) throw new RangeError('fromFrame 超出条目的帧数');
|
||||
const rest = blob instanceof Uint8Array ? blob.subarray(offset) : blob.slice(offset);
|
||||
let index = fromFrame;
|
||||
for await (const chunk of rechunk(rest, maxData, maxData)) {
|
||||
yield await emit(index++, chunk.last, chunk.data, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap0, maxData)) {
|
||||
if (index === 0) {
|
||||
// 第 0 帧:来源长度未知时,到这里才知道放不放得进单帧
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
if (opts.gated === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
gated = true;
|
||||
}
|
||||
yield await emit(0, chunk.last, concat(head, chunk.data), gated);
|
||||
} else {
|
||||
yield await emit(index, chunk.last, chunk.data, true);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
|
||||
* 注意 raw 内容对服务端完全可见。
|
||||
*/
|
||||
export function planRawItem(
|
||||
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
|
||||
source: ContentSource,
|
||||
opts: PlanOptions & { label?: string } = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap, cap)) {
|
||||
if (index === 0) {
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
gated = true;
|
||||
}
|
||||
}
|
||||
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
|
||||
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Labels } from './constants.js';
|
||||
import { utf8, wipe, type Bytes } from './bytes.js';
|
||||
|
||||
/**
|
||||
* 密钥派生(protocol §3)。全部是 HKDF-SHA256,salt 为 channel_id(或 item_id),info 为固定标签。
|
||||
*
|
||||
* 尽量让原始密钥字节不落在 JS 变量里:
|
||||
* - K_enc 导入为不可导出的 HKDF CryptoKey 后立即清零;
|
||||
* - K_item / K_label 直接用 deriveKey 得到不可导出的 AES-GCM CryptoKey。
|
||||
* 令牌(T_read 等)必须以字节形式放进请求头,所以是 Uint8Array。
|
||||
*/
|
||||
|
||||
const subtle = (): SubtleCrypto => globalThis.crypto.subtle;
|
||||
|
||||
function importHkdf(ikm: Uint8Array): Promise<CryptoKey> {
|
||||
return subtle().importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits', 'deriveKey']);
|
||||
}
|
||||
|
||||
async function hkdfBits(key: CryptoKey, salt: Uint8Array, label: string): Promise<Bytes> {
|
||||
const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) }, key, 256);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
function hkdfAesKey(key: CryptoKey, salt: Uint8Array, label: string): Promise<CryptoKey> {
|
||||
return subtle().deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) },
|
||||
key,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChannelKeys {
|
||||
/** 用于派生每个条目的密钥。不可导出,原始字节已清零。 */
|
||||
encRoot: CryptoKey;
|
||||
/** 设备名加密密钥。不可导出。 */
|
||||
labelKey: CryptoKey;
|
||||
readToken: Bytes;
|
||||
writeToken: Bytes;
|
||||
}
|
||||
|
||||
/** 由频道密钥 K 派生。channelId 作为 HKDF 的 salt。 */
|
||||
export async function deriveChannelKeys(channelId: Uint8Array, k: Uint8Array): Promise<ChannelKeys> {
|
||||
const root = await importHkdf(k);
|
||||
|
||||
const kEnc = await hkdfBits(root, channelId, Labels.enc);
|
||||
const encRoot = await importHkdf(kEnc);
|
||||
wipe(kEnc);
|
||||
|
||||
return {
|
||||
encRoot,
|
||||
labelKey: await hkdfAesKey(encRoot, channelId, Labels.label),
|
||||
readToken: await hkdfBits(root, channelId, Labels.read),
|
||||
writeToken: await hkdfBits(root, channelId, Labels.write),
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有者令牌由管理密钥 A 派生。raw 模式同样如此。 */
|
||||
export async function deriveOwnerToken(channelId: Uint8Array, a: Uint8Array): Promise<Bytes> {
|
||||
return hkdfBits(await importHkdf(a), channelId, Labels.owner);
|
||||
}
|
||||
|
||||
/** 每个条目独立的 AES-GCM 密钥,所以同一条目内 frame_index 唯一即保证 IV 唯一。 */
|
||||
export function deriveItemKey(encRoot: CryptoKey, itemId: Uint8Array): Promise<CryptoKey> {
|
||||
return hkdfAesKey(encRoot, itemId, Labels.item);
|
||||
}
|
||||
|
||||
export async function sha256(data: Uint8Array): Promise<Bytes> {
|
||||
return new Uint8Array(await subtle().digest('SHA-256', data as Bytes));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
|
||||
import { fromBase64Url, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { LinkError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 频道链接(protocol §2):
|
||||
* enc 所有者 /c/{id}#k=<K>&a=<A> 成员 /c/{id}#k=<K>
|
||||
* raw 所有者 /c/{id}#a=<A>[&w=<W>] 成员 /c/{id}[#w=<W>]
|
||||
*
|
||||
* 密钥只放在 fragment 里,浏览器不会把它发给服务器。降级防御:带 k 就是 enc,否则是 raw。
|
||||
*/
|
||||
|
||||
export interface ParsedLink {
|
||||
/** 频道 id 的 base64url 文本。 */
|
||||
id: string;
|
||||
idBytes: Bytes;
|
||||
/** 带 k 即 enc,否则 raw。客户端必须据此核对服务端报告的模式。 */
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
/** 频道密钥,仅 enc。 */
|
||||
k?: Bytes;
|
||||
/** 管理密钥,仅所有者链接。 */
|
||||
a?: Bytes;
|
||||
/** raw 模式成员的写密钥。 */
|
||||
w?: Bytes;
|
||||
}
|
||||
|
||||
const PATH = /(?:^|\/)c\/([A-Za-z0-9_-]{22})\/?$/;
|
||||
|
||||
export function parseLink(input: string | URL): ParsedLink {
|
||||
let url: URL;
|
||||
try {
|
||||
url = typeof input === 'string' ? new URL(input) : input;
|
||||
} catch {
|
||||
throw new LinkError('不是合法的链接');
|
||||
}
|
||||
|
||||
const match = PATH.exec(url.pathname);
|
||||
if (!match) throw new LinkError('链接里没有频道 id');
|
||||
const id = match[1] as string;
|
||||
const idBytes = fromBase64Url(id, ID_SIZE);
|
||||
if (!idBytes) throw new LinkError('频道 id 不合法');
|
||||
|
||||
// 密钥放在查询串里会随请求发给服务器,等于已经泄露,必须拒绝而不是悄悄当成 raw 链接
|
||||
for (const name of ['k', 'a', 'w']) {
|
||||
if (url.searchParams.has(name)) throw new LinkError('密钥必须放在 # 后面,放在 ? 后面会被发送给服务器');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash);
|
||||
const key = (name: string): Bytes | undefined => {
|
||||
const v = params.get(name);
|
||||
if (v === null) return undefined;
|
||||
const bytes = fromBase64Url(v, KEY_SIZE);
|
||||
if (!bytes) throw new LinkError(`链接里的 ${name} 不合法`);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const k = key('k');
|
||||
const a = key('a');
|
||||
const w = key('w');
|
||||
|
||||
// enc 频道的写令牌由 K 派生,出现 w 说明链接被拼错或被篡改
|
||||
if (k && w) throw new LinkError('enc 链接不应包含 w');
|
||||
|
||||
const link: ParsedLink = {
|
||||
id,
|
||||
idBytes,
|
||||
mode: k ? 'enc' : 'raw',
|
||||
role: a ? 'owner' : 'member',
|
||||
};
|
||||
if (k) link.k = k;
|
||||
if (a) link.a = a;
|
||||
if (w) link.w = w;
|
||||
return link;
|
||||
}
|
||||
|
||||
export interface LinkParts {
|
||||
id: string;
|
||||
k?: Uint8Array;
|
||||
a?: Uint8Array;
|
||||
w?: Uint8Array;
|
||||
}
|
||||
|
||||
/** 生成链接。origin 如 "https://app.example.com",路径固定为 /c/{id}。 */
|
||||
export function buildLink(origin: string, parts: LinkParts): string {
|
||||
const params: string[] = [];
|
||||
if (parts.k) params.push(`k=${toBase64Url(parts.k)}`);
|
||||
if (parts.a) params.push(`a=${toBase64Url(parts.a)}`);
|
||||
if (parts.w) params.push(`w=${toBase64Url(parts.w)}`);
|
||||
const base = `${origin.replace(/\/+$/, '')}/c/${parts.id}`;
|
||||
return params.length > 0 ? `${base}#${params.join('&')}` : base;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HEADER_SIZE, ID_SIZE } from './constants.js';
|
||||
import { readU32, readU64, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
|
||||
/** 墓碑原因(protocol §8)。 */
|
||||
export type RemovedReason = 'deleted' | 'expired' | 'evicted' | 'download_limit' | 'superseded';
|
||||
|
||||
const REASONS: readonly RemovedReason[] = ['deleted', 'expired', 'evicted', 'download_limit', 'superseded'];
|
||||
|
||||
export type LogRecord =
|
||||
| { kind: 'frame'; seq: number; envelope: Bytes }
|
||||
| { kind: 'tombstone'; seq: number; itemId: string; reason: RemovedReason }
|
||||
| { kind: 'announce'; seq: number; itemId: string; flags: number; createdAt: number };
|
||||
|
||||
const KIND_FRAME = 1;
|
||||
const KIND_TOMBSTONE = 2;
|
||||
const KIND_ANNOUNCE = 3;
|
||||
|
||||
/** body 的字节数,不够返回 null。 */
|
||||
function bodyLength(kind: number, buf: Uint8Array, bodyStart: number): number | null {
|
||||
switch (kind) {
|
||||
case KIND_FRAME: {
|
||||
// 封装自带 payload_len:body 长度 = 26 + payload_len
|
||||
if (buf.length < bodyStart + HEADER_SIZE) return null;
|
||||
return HEADER_SIZE + readU32(buf, bodyStart + 22);
|
||||
}
|
||||
case KIND_TOMBSTONE:
|
||||
return ID_SIZE + 1;
|
||||
case KIND_ANNOUNCE:
|
||||
return ID_SIZE + 1 + 8;
|
||||
default:
|
||||
throw new IntegrityError('bad_envelope');
|
||||
}
|
||||
}
|
||||
|
||||
function parseOne(buf: Uint8Array, offset: number): { record: LogRecord; next: number } {
|
||||
if (buf.length < offset + 9) throw new IntegrityError('bad_envelope');
|
||||
const seq = readU64(buf, offset);
|
||||
const kind = buf[offset + 8] as number;
|
||||
const bodyStart = offset + 9;
|
||||
|
||||
const len = bodyLength(kind, buf, bodyStart);
|
||||
if (len === null || buf.length < bodyStart + len) throw new IntegrityError('bad_envelope');
|
||||
const body = buf.subarray(bodyStart, bodyStart + len);
|
||||
const next = bodyStart + len;
|
||||
|
||||
switch (kind) {
|
||||
case KIND_FRAME:
|
||||
return { record: { kind: 'frame', seq, envelope: body.slice() }, next };
|
||||
case KIND_TOMBSTONE: {
|
||||
const reason = REASONS[body[ID_SIZE] as number];
|
||||
if (!reason) throw new IntegrityError('bad_envelope');
|
||||
return { record: { kind: 'tombstone', seq, itemId: toBase64Url(body.subarray(0, ID_SIZE)), reason }, next };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
record: {
|
||||
kind: 'announce',
|
||||
seq,
|
||||
itemId: toBase64Url(body.subarray(0, ID_SIZE)),
|
||||
flags: body[ID_SIZE] as number,
|
||||
createdAt: readU64(body, ID_SIZE + 1),
|
||||
},
|
||||
next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 log 接口返回的记录拼接(长轮询的响应体)。 */
|
||||
export function parseLogRecords(data: Uint8Array): LogRecord[] {
|
||||
const out: LogRecord[] = [];
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const { record, next } = parseOne(data, offset);
|
||||
out.push(record);
|
||||
offset = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 解析恰好一条记录(WebSocket 的一条二进制消息)。多余或缺少字节都视为非法。 */
|
||||
export function parseLogRecord(data: Uint8Array): LogRecord {
|
||||
const { record, next } = parseOne(data, 0);
|
||||
if (next !== data.length) throw new IntegrityError('bad_envelope');
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Flags, type ChannelMode, type ItemKind } from './constants.js';
|
||||
import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, type IntegrityReason } from './errors.js';
|
||||
import { openFrame, parseEnvelope, type Envelope } from './frame.js';
|
||||
import { sha256 } from './kdf.js';
|
||||
import { decodeFirstFrame, type ItemMeta } from './item.js';
|
||||
import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js';
|
||||
|
||||
/** 接收端需要的会话信息。由 ChannelSession 提供。 */
|
||||
export interface ReaderContext {
|
||||
channelId: Uint8Array;
|
||||
mode: ChannelMode;
|
||||
/** enc 模式:取某个条目的 AES-GCM 密钥。 */
|
||||
itemKey(itemId: Uint8Array): Promise<CryptoKey>;
|
||||
}
|
||||
|
||||
export type ReceivedKind = ItemKind | 'raw';
|
||||
|
||||
export interface ReceivedItem {
|
||||
itemId: string;
|
||||
/** 帧所在日志记录的 seq(经 pushFrame 单独喂入且未提供 seq 时为空)。 */
|
||||
seq: number | undefined;
|
||||
kind: ReceivedKind;
|
||||
/** 仅 enc 模式。 */
|
||||
meta?: ItemMeta;
|
||||
snapshot: boolean;
|
||||
gated: boolean;
|
||||
/** 已收到已认证的 FINAL,且 0..FINAL 的帧全部到齐。 */
|
||||
complete: boolean;
|
||||
/** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */
|
||||
content: Bytes;
|
||||
}
|
||||
|
||||
export type ItemEvent =
|
||||
/** GATED 条目的通知:字节需要另外拉取(GET /items/{id}/frames),拉取计入下载次数。 */
|
||||
| { type: 'announce'; itemId: string; seq: number; flags: number; createdAt: number }
|
||||
/** 一个条目有新内容或已完成。小内容(单帧)只会触发一次,complete 为 true。 */
|
||||
| { type: 'item'; item: ReceivedItem }
|
||||
/** 条目被删除、过期、淘汰、取代或下载达上限。 */
|
||||
| { type: 'removed'; itemId: string; seq: number; reason: RemovedReason }
|
||||
/** 一帧被拒绝(篡改、乱序、模式不符等)。该条目的缓冲状态已丢弃。 */
|
||||
| { type: 'rejected'; itemId: string | undefined; seq: number | undefined; reason: IntegrityReason };
|
||||
|
||||
interface FrameState {
|
||||
content: Bytes;
|
||||
digest: Bytes;
|
||||
}
|
||||
|
||||
interface ItemState {
|
||||
itemFlags: number;
|
||||
frames: Map<number, FrameState>;
|
||||
finalIndex: number | undefined;
|
||||
maxIndex: number;
|
||||
meta: ItemMeta | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目装配器:喂入日志记录或条目的帧,吐出解密、校验后的事件。
|
||||
*
|
||||
* 校验失败**不抛异常**,而是产生 `rejected` 事件并丢弃该条目的缓冲:
|
||||
* 一个坏帧(或恶意服务端)不应让整个订阅挂掉。直接调用 openFrame 才会抛 IntegrityError。
|
||||
*/
|
||||
export class ItemReader {
|
||||
readonly #ctx: ReaderContext;
|
||||
readonly #open = new Map<string, ItemState>();
|
||||
/** 已完成的条目。回放时重复到达的帧直接忽略,不会让条目「复活」。 */
|
||||
readonly #done = new Set<string>();
|
||||
|
||||
constructor(ctx: ReaderContext) {
|
||||
this.#ctx = ctx;
|
||||
}
|
||||
|
||||
/** 喂入一条日志记录(WebSocket 的一条二进制消息,或 parseLogRecords 的一项)。 */
|
||||
async push(input: Uint8Array | LogRecord): Promise<ItemEvent[]> {
|
||||
let record: LogRecord;
|
||||
try {
|
||||
record = input instanceof Uint8Array ? parseLogRecord(input) : input;
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq: undefined, reason: e.reason }];
|
||||
throw e;
|
||||
}
|
||||
|
||||
switch (record.kind) {
|
||||
case 'announce':
|
||||
return [{ type: 'announce', itemId: record.itemId, seq: record.seq, flags: record.flags, createdAt: record.createdAt }];
|
||||
case 'tombstone':
|
||||
this.#open.delete(record.itemId);
|
||||
this.#done.delete(record.itemId);
|
||||
return [{ type: 'removed', itemId: record.itemId, seq: record.seq, reason: record.reason }];
|
||||
case 'frame':
|
||||
return this.pushFrame(record.envelope, record.seq);
|
||||
}
|
||||
}
|
||||
|
||||
/** 喂入一个完整的封装(例如拉取 GATED 条目时按帧切出来的)。 */
|
||||
async pushFrame(envelope: Uint8Array, seq?: number): Promise<ItemEvent[]> {
|
||||
let env: Envelope;
|
||||
try {
|
||||
env = parseEnvelope(envelope);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq, reason: e.reason }];
|
||||
throw e;
|
||||
}
|
||||
|
||||
const itemId = toBase64Url(env.itemId);
|
||||
try {
|
||||
return await this.#accept(env, itemId, seq);
|
||||
} catch (e) {
|
||||
if (!(e instanceof IntegrityError)) throw e;
|
||||
this.#open.delete(itemId);
|
||||
return [{ type: 'rejected', itemId, seq, reason: e.reason }];
|
||||
}
|
||||
}
|
||||
|
||||
/** 丢弃某个条目的缓冲(例如用户取消了下载)。 */
|
||||
forget(itemId: string): void {
|
||||
this.#open.delete(itemId);
|
||||
this.#done.delete(itemId);
|
||||
}
|
||||
|
||||
async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise<ItemEvent[]> {
|
||||
const isEncFrame = (env.flags & Flags.Enc) !== 0;
|
||||
// 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之
|
||||
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
|
||||
if (this.#done.has(itemId)) return [];
|
||||
|
||||
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
|
||||
const gated = (env.flags & Flags.Gated) !== 0;
|
||||
const isFinal = (env.flags & Flags.Final) !== 0;
|
||||
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
|
||||
if (!gated && (env.frameIndex !== 0 || !isFinal)) throw new IntegrityError('bad_envelope');
|
||||
|
||||
let state = this.#open.get(itemId);
|
||||
if (state && state.itemFlags !== itemFlags) throw new IntegrityError('flags_changed');
|
||||
if (state?.finalIndex !== undefined && env.frameIndex > state.finalIndex) throw new IntegrityError('frame_after_final');
|
||||
if (isFinal && state) {
|
||||
if (state.maxIndex > env.frameIndex) throw new IntegrityError('final_conflict');
|
||||
if (state.finalIndex !== undefined && state.finalIndex !== env.frameIndex) throw new IntegrityError('final_conflict');
|
||||
}
|
||||
|
||||
// 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件
|
||||
const digest = await sha256(env.payload);
|
||||
const dup = state?.frames.get(env.frameIndex);
|
||||
if (dup) {
|
||||
if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解密 / 取出内容
|
||||
let data: Bytes;
|
||||
if (this.#ctx.mode === 'enc') {
|
||||
data = await openFrame(await this.#ctx.itemKey(env.itemId), this.#ctx.channelId, env);
|
||||
} else {
|
||||
data = env.payload;
|
||||
}
|
||||
|
||||
let meta = state?.meta;
|
||||
let content = data;
|
||||
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) {
|
||||
const first = decodeFirstFrame(data);
|
||||
meta = first.meta;
|
||||
content = first.content;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
state = { itemFlags, frames: new Map(), finalIndex: undefined, maxIndex: -1, meta: undefined };
|
||||
this.#open.set(itemId, state);
|
||||
}
|
||||
state.frames.set(env.frameIndex, { content, digest });
|
||||
state.maxIndex = Math.max(state.maxIndex, env.frameIndex);
|
||||
if (isFinal) state.finalIndex = env.frameIndex;
|
||||
if (meta) state.meta = meta;
|
||||
|
||||
// 拼接到第一个缺口为止
|
||||
const parts: Uint8Array[] = [];
|
||||
for (let i = 0; state.frames.has(i); i++) parts.push((state.frames.get(i) as FrameState).content);
|
||||
const complete = state.finalIndex !== undefined && parts.length === state.finalIndex + 1;
|
||||
|
||||
const item: ReceivedItem = {
|
||||
itemId,
|
||||
seq,
|
||||
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'),
|
||||
snapshot: (itemFlags & Flags.Snapshot) !== 0,
|
||||
gated,
|
||||
complete,
|
||||
content: concat(...parts),
|
||||
};
|
||||
if (state.meta) item.meta = state.meta;
|
||||
|
||||
if (complete) {
|
||||
this.#open.delete(itemId);
|
||||
this.#done.add(itemId);
|
||||
}
|
||||
return [{ type: 'item', item }];
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 便利函数 ─────────────────────────
|
||||
|
||||
/** 内容按 UTF-8 解码,非法序列抛 TypeError。 */
|
||||
export function itemText(item: Pick<ReceivedItem, 'content'>): string {
|
||||
const text = tryDecodeUtf8(item.content);
|
||||
if (text === null) throw new TypeError('条目内容不是合法的 UTF-8');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function itemJson(item: Pick<ReceivedItem, 'content'>): unknown {
|
||||
return JSON.parse(itemText(item));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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;
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Flags } from './constants.js';
|
||||
import { equalBytes, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js';
|
||||
import { decodeFirstFrame, type ItemMeta } from './item.js';
|
||||
import type { ReaderContext } from './reader.js';
|
||||
|
||||
/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */
|
||||
class ByteQueue {
|
||||
readonly #parts: Uint8Array[] = [];
|
||||
#length = 0;
|
||||
|
||||
get length(): number {
|
||||
return this.#length;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): void {
|
||||
if (chunk.length === 0) return;
|
||||
this.#parts.push(chunk);
|
||||
this.#length += chunk.length;
|
||||
}
|
||||
|
||||
/** 复制前 n 个字节,不消耗。 */
|
||||
peek(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
for (const part of this.#parts) {
|
||||
if (filled >= n) break;
|
||||
const take = Math.min(part.length, n - filled);
|
||||
out.set(part.subarray(0, take), filled);
|
||||
filled += take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
take(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
while (filled < n) {
|
||||
const head = this.#parts[0] as Uint8Array;
|
||||
const need = n - filled;
|
||||
if (head.length <= need) {
|
||||
out.set(head, filled);
|
||||
filled += head.length;
|
||||
this.#parts.shift();
|
||||
} else {
|
||||
out.set(head.subarray(0, need), filled);
|
||||
this.#parts[0] = head.subarray(need);
|
||||
filled += need;
|
||||
}
|
||||
}
|
||||
this.#length -= n;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
const reader = source.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
yield* source as AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「封装首尾相接」的字节流(GET /items/{id}/frames 的响应体)切成一个个完整封装。
|
||||
* 连接中途断开会在末尾留下半个封装:这是正常的网络情况,不是篡改,所以直接丢弃,
|
||||
* 调用方根据 decryptItemStream 报告的 nextIndex 续传即可。
|
||||
*/
|
||||
export async function* splitEnvelopes(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
): AsyncGenerator<Bytes, void, void> {
|
||||
const queue = new ByteQueue();
|
||||
for await (const chunk of bytesOf(source)) {
|
||||
queue.push(chunk);
|
||||
for (;;) {
|
||||
if (queue.length < 26) break;
|
||||
const total = peekEnvelopeLength(queue.peek(26), MAX_ENC_PAYLOAD);
|
||||
if (total === null || queue.length < total) break;
|
||||
yield queue.take(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DecryptStreamOptions {
|
||||
/** 这次响应从哪一帧开始(拉取时的 from 参数),默认 0。 */
|
||||
fromIndex?: number;
|
||||
/** 期望的条目 id(base64url 解码后的字节)。提供后,混入别的条目的帧会被拒绝。 */
|
||||
itemId?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ItemDecodeResult {
|
||||
/** 已收到已认证的 FINAL。 */
|
||||
complete: boolean;
|
||||
/** 下一个期望的帧序号。没收完时用它作为 from 续传(不会重复计下载次数)。 */
|
||||
nextIndex: number;
|
||||
/** 第 0 帧里的元数据;从中间帧续传时没有。 */
|
||||
meta: ItemMeta | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式解密一个条目:边收边解边产出内容,内存占用只有一帧。可以放进 Worker 里运行。
|
||||
*
|
||||
* 逐帧校验:认证、条目 id、帧序号连续、标志一致、FINAL 之后不再有帧。
|
||||
* 任何一项失败都会抛 IntegrityError(流会出错),已产出的内容不可信的部分由调用方丢弃。
|
||||
* 流正常结束但没有 FINAL 不是错误(连接可能被断开),此时 complete 为 false。
|
||||
*/
|
||||
export async function* decryptItemFrames(
|
||||
ctx: ReaderContext,
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
opts: DecryptStreamOptions = {},
|
||||
): AsyncGenerator<Bytes, ItemDecodeResult, void> {
|
||||
let expected = opts.fromIndex ?? 0;
|
||||
let itemId = opts.itemId;
|
||||
let flagsMask: number | undefined;
|
||||
let finalSeen = false;
|
||||
let meta: ItemMeta | undefined;
|
||||
|
||||
for await (const bytes of splitEnvelopes(source)) {
|
||||
const env = parseEnvelope(bytes);
|
||||
|
||||
const isEnc = (env.flags & Flags.Enc) !== 0;
|
||||
if (isEnc !== (ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
|
||||
|
||||
if (!itemId) itemId = env.itemId;
|
||||
else if (!equalBytes(itemId, env.itemId)) throw new IntegrityError('item_mismatch');
|
||||
|
||||
if (finalSeen) throw new IntegrityError('frame_after_final');
|
||||
if (env.frameIndex !== expected) throw new IntegrityError('gap');
|
||||
|
||||
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
|
||||
if (flagsMask === undefined) flagsMask = itemFlags;
|
||||
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
|
||||
|
||||
let data: Bytes;
|
||||
if (ctx.mode === 'enc') {
|
||||
data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env);
|
||||
if (env.frameIndex === 0) {
|
||||
const first = decodeFirstFrame(data);
|
||||
meta = first.meta;
|
||||
data = first.content;
|
||||
}
|
||||
} else {
|
||||
data = env.payload;
|
||||
}
|
||||
|
||||
expected++;
|
||||
if ((env.flags & Flags.Final) !== 0) finalSeen = true;
|
||||
if (data.length > 0) yield data;
|
||||
}
|
||||
|
||||
return { complete: finalSeen, nextIndex: expected, meta };
|
||||
}
|
||||
|
||||
/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */
|
||||
export function decryptItemStream(
|
||||
ctx: ReaderContext,
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
opts: DecryptStreamOptions = {},
|
||||
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
|
||||
const gen = decryptItemFrames(ctx, source, opts);
|
||||
let resolve!: (r: ItemDecodeResult) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const result = new Promise<ItemDecodeResult>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
result.catch(() => {}); // 错误通过流本身传播,避免未处理的 rejection
|
||||
|
||||
const content = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const r = await gen.next();
|
||||
if (r.done) {
|
||||
resolve(r.value);
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(r.value);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
controller.error(e);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined });
|
||||
},
|
||||
});
|
||||
return { content, result };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user