feat(web): add account workspaces and signed content flows
This commit is contained in:
1 parent
7b8d61d1ba
commit
b5cf3d5958
117 files changed
+2789
-1674
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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 到文件。 */
|
||||
|
||||
Reference in new issue
Block a user