feat(web): add account workspaces and signed content flows

This commit is contained in:
justme committed 2026-10-14 04:06:03 +08:00
1 parent 7b8d61d1ba
commit b5cf3d5958
117 files changed
+2789 -1674

No files matched your search

+3
View File
@@ -13,6 +13,7 @@
*/
export { ChannelSession, openLink } from './session.js';
export { parseEnvelope } from './frame.js';
export type {
AuthRole,
ChannelSettings,
@@ -40,6 +41,8 @@ export { buildLink, parseLink } from './link.js';
export type { LinkParts, ParsedLink } from './link.js';
export { fromBase64Url, toBase64Url } from './bytes.js';
export { contentEventPayload, frameManifestHash, generateSigningKeyPair, importSigningPublicKey, signStatement, verifyStatement } from './signatures.js';
export type { ContentEventStatement } from './signatures.js';
export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js';
export type { ChannelMode, ItemKind } from './constants.js';
+9 -3
View File
@@ -3,6 +3,7 @@ import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './by
import { IntegrityError, type IntegrityReason } from './errors.js';
import { openFrame, parseEnvelope, type Envelope } from './frame.js';
import { sha256 } from './kdf.js';
import { frameManifestHash } from './signatures.js';
import { decodeFirstFrame, type ItemMeta } from './item.js';
import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js';
@@ -29,6 +30,8 @@ export interface ReceivedItem {
complete: boolean;
/** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */
content: Bytes;
/** SHA-256 manifest over the complete ciphertext envelopes in frame-index order. */
ciphertextManifestHash?: Bytes;
}
export type ItemEvent =
@@ -104,7 +107,7 @@ export class ItemReader {
const itemId = toBase64Url(env.itemId);
try {
return await this.#accept(env, itemId, seq);
return await this.#accept(env, itemId, seq, Uint8Array.from(envelope));
} catch (e) {
if (!(e instanceof IntegrityError)) throw e;
this.#open.delete(itemId);
@@ -118,7 +121,7 @@ export class ItemReader {
this.#done.delete(itemId);
}
async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise<ItemEvent[]> {
async #accept(env: Envelope, itemId: string, seq: number | undefined, envelope: Bytes): Promise<ItemEvent[]> {
const isEncFrame = (env.flags & Flags.Enc) !== 0;
// 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
@@ -139,7 +142,7 @@ export class ItemReader {
}
// 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件
const digest = await sha256(env.payload);
const digest = await sha256(envelope);
const dup = state?.frames.get(env.frameIndex);
if (dup) {
if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch');
@@ -186,6 +189,9 @@ export class ItemReader {
content: concat(...parts),
};
if (state.meta) item.meta = state.meta;
if (complete) item.ciphertextManifestHash = Uint8Array.from(await frameManifestHash(
Array.from({ length: state.finalIndex! + 1 }, (_, index) => state!.frames.get(index)!.digest),
));
if (complete) {
this.#open.delete(itemId);
+3
View File
@@ -92,6 +92,8 @@ interface SessionInit {
* JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。
*/
export class ChannelSession {
/** Applications may supply independently authorized object keys instead of the space root. */
objectKeyResolver?: (itemId: string) => Promise<CryptoKey>;
readonly id: string;
readonly idBytes: Bytes;
readonly mode: ChannelMode;
@@ -247,6 +249,7 @@ export class ChannelSession {
/** 一个条目的 AES-GCM 密钥,带缓存。 */
itemKey(itemId: Uint8Array): Promise<CryptoKey> {
if (this.objectKeyResolver) return this.objectKeyResolver(toBase64Url(itemId));
if (!this.#keys) throw new CapabilityError('enc');
const cacheKey = toBase64Url(itemId);
let key = this.#itemKeys.get(cacheKey);
+110
View File
@@ -0,0 +1,110 @@
const SIGNATURE_MAGIC = new Uint8Array([0x57, 0x44, 0x53, 0x01]); // WDS\x01
const MAX_KIND_BYTES = 64;
const MAX_PAYLOAD_BYTES = 1_048_576;
const source = (value: Uint8Array): ArrayBuffer => {
const copy = new Uint8Array(value.length);
copy.set(value);
return copy.buffer;
};
export interface ContentEventStatement {
spaceId: Uint8Array;
channelId: string;
username: string;
deviceId: string;
eventId: Uint8Array;
eventKind: string;
domain: 'chat' | 'files';
epoch: number;
itemId: Uint8Array;
frameCount: number;
manifestHash: Uint8Array;
targetItemId?: Uint8Array;
}
export function contentEventPayload(event: ContentEventStatement): Uint8Array {
const channel = new TextEncoder().encode(event.channelId.toLowerCase());
const username = new TextEncoder().encode(event.username.toLowerCase());
const device = new TextEncoder().encode(event.deviceId.toLowerCase());
const kind = new TextEncoder().encode(event.eventKind);
const target = event.targetItemId ?? new Uint8Array();
if (event.spaceId.length !== 16 || event.eventId.length !== 16 || event.itemId.length !== 16 || event.manifestHash.length !== 32
|| target.length !== 0 && target.length !== 16 || !Number.isInteger(event.epoch) || event.epoch < 0
|| !Number.isInteger(event.frameCount) || event.frameCount < 1 || event.frameCount > 65_536
|| !/^[0-9a-f-]{36}$/.test(event.channelId.toLowerCase()) || !/^[0-9a-f-]{36}$/.test(event.deviceId.toLowerCase())
|| !/^[a-z0-9_-]{3,32}$/.test(event.username.toLowerCase()) || !/^[a-z][a-z.\-]{0,31}$/.test(event.eventKind))
throw new TypeError('签名事件字段无效');
const bytes = new Uint8Array(16 + 1 + channel.length + 1 + username.length + 1 + device.length + 16 + 1 + kind.length + 1 + 4 + 16 + 4 + 32 + 1 + 16);
let offset = 0;
bytes.set(event.spaceId, offset); offset += 16;
bytes[offset++] = channel.length; bytes.set(channel, offset); offset += channel.length;
bytes[offset++] = username.length; bytes.set(username, offset); offset += username.length;
bytes[offset++] = device.length; bytes.set(device, offset); offset += device.length;
bytes.set(event.eventId, offset); offset += 16;
bytes[offset++] = kind.length; bytes.set(kind, offset); offset += kind.length;
bytes[offset++] = event.domain === 'chat' ? 1 : 2;
new DataView(bytes.buffer).setUint32(offset, event.epoch, false); offset += 4;
bytes.set(event.itemId, offset); offset += 16;
new DataView(bytes.buffer).setUint32(offset, event.frameCount, false); offset += 4;
bytes.set(event.manifestHash, offset); offset += 32;
bytes[offset++] = target.length === 0 ? 0 : 1;
if (target.length) bytes.set(target, offset);
return bytes;
}
export async function frameManifestHash(frameDigests: readonly Uint8Array[]): Promise<Uint8Array> {
if (frameDigests.length < 1 || frameDigests.length > 65_536 || frameDigests.some(digest => digest.length !== 32))
throw new RangeError('帧清单无效');
const bytes = new Uint8Array(8 + frameDigests.length * 36);
bytes.set([0x57, 0x44, 0x31, 0x4d]); // WD1M
new DataView(bytes.buffer).setUint32(4, frameDigests.length, false);
let offset = 8;
for (let index = 0; index < frameDigests.length; index++) {
new DataView(bytes.buffer).setUint32(offset, index, false); offset += 4;
bytes.set(frameDigests[index]!, offset); offset += 32;
}
return new Uint8Array(await crypto.subtle.digest('SHA-256', source(bytes)));
}
function signingBytes(kind: string, payload: Uint8Array): Uint8Array {
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(kind)) throw new TypeError('签名类型无效');
if (payload.length > MAX_PAYLOAD_BYTES) throw new RangeError('签名内容过大');
const kindBytes = new TextEncoder().encode(kind);
if (kindBytes.length > MAX_KIND_BYTES) throw new RangeError('签名类型过长');
const bytes = new Uint8Array(SIGNATURE_MAGIC.length + 2 + kindBytes.length + 4 + payload.length);
let offset = 0;
bytes.set(SIGNATURE_MAGIC, offset); offset += SIGNATURE_MAGIC.length;
new DataView(bytes.buffer).setUint16(offset, kindBytes.length, false); offset += 2;
bytes.set(kindBytes, offset); offset += kindBytes.length;
new DataView(bytes.buffer).setUint32(offset, payload.length, false); offset += 4;
bytes.set(payload, offset);
return bytes;
}
export async function generateSigningKeyPair(extractable = false): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify']);
}
export async function signStatement(privateKey: CryptoKey, kind: string, payload: Uint8Array): Promise<Uint8Array> {
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, privateKey, source(signingBytes(kind, payload)),
);
const bytes = new Uint8Array(signature);
if (bytes.length !== 64) throw new Error('签名格式无效');
return bytes;
}
export async function verifyStatement(
publicKey: CryptoKey, kind: string, payload: Uint8Array, signature: Uint8Array,
): Promise<boolean> {
if (signature.length !== 64) return false;
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, publicKey, source(signature), source(signingBytes(kind, payload)),
);
}
export async function importSigningPublicKey(key: JsonWebKey): Promise<CryptoKey> {
if (key.kty !== 'EC' || key.crv !== 'P-256' || !key.x || !key.y || key.d !== undefined)
throw new TypeError('签名公钥格式无效');
return crypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
}
+8 -1
View File
@@ -3,6 +3,8 @@ 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 { sha256 } from './kdf.js';
import { frameManifestHash } from './signatures.js';
import type { ReaderContext } from './reader.js';
/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */
@@ -106,6 +108,8 @@ export interface ItemDecodeResult {
nextIndex: number;
/** 第 0 帧里的元数据;从中间帧续传时没有。 */
meta: ItemMeta | undefined;
/** Present only for complete reads starting at frame zero. */
ciphertextManifestHash?: Bytes;
}
/**
@@ -125,8 +129,10 @@ export async function* decryptItemFrames(
let flagsMask: number | undefined;
let finalSeen = false;
let meta: ItemMeta | undefined;
const frameDigests: Bytes[] = [];
for await (const bytes of splitEnvelopes(source)) {
frameDigests.push(await sha256(bytes));
const env = parseEnvelope(bytes);
const isEnc = (env.flags & Flags.Enc) !== 0;
@@ -157,7 +163,8 @@ export async function* decryptItemFrames(
if (data.length > 0) yield data;
}
return { complete: finalSeen, nextIndex: expected, meta };
return { complete: finalSeen, nextIndex: expected, meta,
...(finalSeen && (opts.fromIndex ?? 0) === 0 ? { ciphertextManifestHash: Uint8Array.from(await frameManifestHash(frameDigests)) } : {}) };
}
/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */