chore(repo): initialize WebDrop monorepo

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:22 +08:00
commit 53768a195d
39 files changed
+9063

No files matched your search

+175
View File
@@ -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);
}
+49
View File
@@ -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';
+64
View File
@@ -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);
}
}
+144
View File
@@ -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);
}
+55
View File
@@ -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';
+42
View File
@@ -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);
+340
View File
@@ -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() };
}
+71
View File
@@ -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));
}
+92
View File
@@ -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;
}
+86
View File
@@ -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;
}
+210
View File
@@ -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));
}
+401
View File
@@ -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;
+199
View File
@@ -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 };
}