Files
WebDrop/web/src/core-bridge/worker/channelDataKeys.ts
T
justme d77ca1f113 perf(web): reuse cached crypto state and structure when decrypting and sending
Decrypting each history item no longer refetches the whole crypto state, and
sending a message only needs the object and frame requests.
2026-10-16 06:00:16 +08:00

238 lines
18 KiB
TypeScript

import { contentEventPayload, fromBase64Url, importSigningPublicKey, toBase64Url, verifyStatement, type ChannelSession } from '@webdrop/core';
import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '../generated/api';
import { getResource, saveResource, type SavedResource } from '../database';
import { apiFetch } from './http';
import { accountPublicKey, accountToken, requireAccount } from './accountSession';
import { decryptInvitation, encryptInvitation } from './accountCrypto';
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
import { cachedWorkspace, loadWorkspace } from './workspaceStructure';
import { pinSigningRoot } from './accountPersistence';
import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning';
const objectKeys = new Map<string, CryptoKey>();
const states = new Map<string, CryptoState>();
const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`;
const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`;
const loaded = new Map<string, DataKeys>();
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); loaded.clear(); }
export function attachDataKeys(session: ChannelSession, resource: SavedResource): void {
if (resource.kind !== 'channel') return;
session.objectKeyResolver = async itemId => {
const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached;
// 内存里已有这个对象和它的数据域密钥就直接用;否则才重新拉(每次都拉整份 crypto 状态,历史越长越慢)
const { keys } = await cachedKeys(resource, session, data => {
const known = data.state.objects.find(o => o.itemId === itemId);
return Boolean(known && data.keys[domainId(known.channelId, known.domain, known.epoch)]);
});
const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId);
if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域');
const key = keys[domainId(object.channelId, object.domain, object.epoch)];
if (!key) throw new Error('未获得该频道数据域的访问授权');
const unwrapped = await unwrapObjectKey(key, object.keyEnvelope, { spaceId: resource.id, channelId: object.channelId, domain: object.domain, epoch: object.epoch, itemId });
objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped;
};
}
type DataKeys = { state: CryptoState; keys: Record<string, string> };
const pendingKeys = new Map<string, Promise<DataKeys>>();
/** 同一空间同时发起的读取合并成一次请求 */
export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
let task = pendingKeys.get(resource.id);
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); }
return task;
}
/** 内存里的结果满足 enough 就复用,不满足再请求 */
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
const have = loaded.get(resource.id);
return have && enough(have) ? have : readDataKeys(resource, session);
}
async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>);
states.set(resource.id, state);
const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys };
for (const envelope of state.envelopes) {
const { unlockedAccount } = await import('./account'); const account = unlockedAccount();
const id = domainId(envelope.channelId, envelope.domain, envelope.epoch);
if (keys[id]) continue;
const plain = await decryptInvitation(envelope.payload, account.username, contextId(resource.id, envelope.channelId, envelope.domain, envelope.epoch), account.profile.keys.privateKey);
const value = JSON.parse(plain) as { channelId: string; domain: string; epoch: number; key: string };
if (value.channelId !== envelope.channelId || value.domain !== envelope.domain || value.epoch !== envelope.epoch || typeof value.key !== 'string') throw new Error('数据域密钥包与授权范围不匹配');
keys[id] = value.key;
}
if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys });
const result = { state, keys };
loaded.set(resource.id, result);
return result;
}
async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise<DomainEnvelope> {
const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise<PublicKeyBody>)).public_key) as JsonWebKey;
return { channelId, domain, epoch, username, payload: await encryptInvitation(JSON.stringify({ channelId, domain, epoch, key }), username, contextId(spaceId, channelId, domain, epoch), publicKey) };
}
export async function packageInvitationDomains(resource: SavedResource, session: ChannelSession, plan: InvitationCryptoPlan): Promise<DomainEnvelope[]> {
if (plan.channelId !== (await loadWorkspace(resource.id)).structure.defaultChannelId) throw new Error('服务端授权计划的默认频道不匹配');
if (resource.mode !== 'enc') {
if (plan.batches.length) throw new Error('未加密空间收到加密授权计划');
return [];
}
if (!session.can('owner')) throw new Error('只有空间主可以封装频道密钥');
const { keys } = await readDataKeys(resource, session);
const result: DomainEnvelope[] = [];
for (const batch of plan.batches) {
if (batch.domain !== 'chat' && batch.domain !== 'files' || batch.epoch < 1 || batch.recipients.length < 1 || new Set(batch.recipients).size !== batch.recipients.length)
throw new Error('服务端加密授权计划格式无效');
const keyId = domainId(plan.channelId, batch.domain, batch.epoch);
let key = keys[keyId];
if (!key) {
if (!batch.rotate) throw new Error('缺少历史 epoch 密钥,不能为受邀成员重建或替换密钥');
key = randomDomainKey(); keys[keyId] = key;
const latest = await getResource(resource.id);
if (!latest) throw new Error('空间本地密钥状态不存在');
await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [keyId]: key } });
}
for (const username of batch.recipients) result.push(await envelope(resource.id, plan.channelId, batch.domain, batch.epoch, key, username));
}
return result;
}
export async function initializeChannelKeys(resource: SavedResource, session: ChannelSession, channelId: string): Promise<void> {
if (resource.mode !== 'enc') return;
const { structure } = await loadWorkspace(resource.id); const channel = structure.channels.find(c => c.id === channelId);
if (!channel) throw new Error('频道不存在');
if (!session.can('owner')) return;
const owner = requireAccount();
for (const domain of ['chat', 'files'] as const) {
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
if (epoch > 0) continue;
const key = randomDomainKey(); const latest = (await getResource(resource.id))!;
const id = domainId(channelId, domain, 1);
const existing = latest.domainKeys?.[id]; const selected = existing ?? key;
if (!existing) await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [id]: selected } });
const packaged = await envelope(resource.id, channelId, domain, 1, selected, owner);
await apiFetch(`/spaces/${resource.id}/crypto/domains`, { method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId, domain, epoch: 1, envelopes: [packaged] }) });
}
await loadWorkspace(resource.id);
}
export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number }
export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise<PreparedObject> {
const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes);
let channel = (await cachedWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
if (!channel) channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
if (!channel) throw new Error('请先创建频道');
if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) {
await initializeChannelKeys(resource, session, channelId);
channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId)!;
}
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
let keyEnvelope = '';
if (resource.mode === 'enc') {
const { keys } = await cachedKeys(resource, session, data => Boolean(data.keys[domainId(channelId, domain, epoch)])); const key = keys[domainId(channelId, domain, epoch)];
if (!key) throw new Error('未获得该频道数据域的访问授权');
const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope;
objectKeys.set(`${resource.id}:${itemId}`, created.key);
}
const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId, channelId, domain, epoch, keyEnvelope }) }).then(r => r.json() as Promise<ObjectEnvelope>);
const state = states.get(resource.id); if (state) state.objects.push(object);
return { itemIdBytes: idBytes, itemId, channelId, domain, epoch };
}
export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise<boolean> {
if (resource.mode !== 'enc') return true;
let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId);
let keys = (await getResource(resource.id))?.domainKeys ?? {};
if (!object) { const refreshed = await readDataKeys(resource, session); state = refreshed.state; keys = refreshed.keys; object = state.objects.find(o => o.itemId === itemId); }
if (!object) return false;
return Boolean(keys[domainId(object.channelId, object.domain, object.epoch)]);
}
export async function verifyObjectEvent(resource: SavedResource, session: ChannelSession, itemId: string, manifestHash: Uint8Array): Promise<boolean> {
if (resource.mode === 'raw' && !accountToken()) return true;
let state = states.get(resource.id);
let object = state?.objects.find(row => row.itemId === itemId);
if (!object?.event?.signature) {
const refreshed = await readDataKeys(resource, session); state = refreshed.state;
object = state.objects.find(row => row.itemId === itemId);
}
if (resource.mode === 'raw' && !object?.event?.signature) return true;
const signed = object?.event;
if (!object || !signed || !object.authorAccount || !signed.eventId || !signed.kind || !signed.frameCount
|| !signed.manifestHash || !signed.signerDeviceId || !signed.signerRootPublicKey
|| !signed.signerDevicePublicKey || !signed.deviceCertificateSignature || !signed.signature) return false;
const expectedManifest = fromBase64Url(signed.manifestHash, 32);
const spaceId = fromBase64Url(resource.id, 16); const eventId = fromBase64Url(signed.eventId, 16);
const itemBytes = fromBase64Url(itemId, 16); const eventSignature = fromBase64Url(signed.signature, 64);
const deviceCertificateSignature = fromBase64Url(signed.deviceCertificateSignature, 64);
if (!expectedManifest || !spaceId || !eventId || !itemBytes || !eventSignature || !deviceCertificateSignature
|| expectedManifest.length !== manifestHash.length || !expectedManifest.every((byte, index) => byte === manifestHash[index])) return false;
const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
const rootJwk = JSON.parse(signed.signerRootPublicKey) as JsonWebKey;
if (object.authorAccount === local.username && local.profile.keys.signing
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
await pinSigningRoot(object.authorAccount, rootJwk);
const root = await importSigningPublicKey(rootJwk);
const deviceJwk = JSON.parse(signed.signerDevicePublicKey) as JsonWebKey;
if (!await verifyDeviceCertificate(object.authorAccount, signed.signerDeviceId, deviceJwk, deviceCertificateSignature, root)) return false;
const deviceKey = await importSigningPublicKey(deviceJwk);
const domain = object.domain;
if (domain !== 'chat' && domain !== 'files') return false;
const targetItemId = signed.targetItemId ? fromBase64Url(signed.targetItemId, 16) : undefined;
if (signed.targetItemId && !targetItemId) return false;
const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount,
deviceId: signed.signerDeviceId, eventId, eventKind: signed.kind, domain, epoch: object.epoch,
itemId: itemBytes, frameCount: signed.frameCount, manifestHash: expectedManifest,
...(targetItemId ? { targetItemId } : {}) });
return verifyStatement(deviceKey, 'content.event', payload, eventSignature);
}
export async function objectEventProjection(resource: SavedResource, session: ChannelSession, itemId: string): Promise<{ kind: string; targetItemId?: string } | undefined> {
let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId);
if (!object) { state = (await readDataKeys(resource, session)).state; object = state.objects.find(row => row.itemId === itemId); }
return object?.event?.signature && object.event.kind
? { kind: object.event.kind, ...(object.event.targetItemId ? { targetItemId: object.event.targetItemId } : {}) }
: undefined;
}
export async function signObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise<Record<string, string>> {
const { state } = await readDataKeys(resource, session);
const object = state.objects.find(row => row.itemId === itemId);
const signed = object?.event;
if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files'))
throw new Error('对象缺少已验签的密文清单,不能生成删除事件');
const manifestHash = fromBase64Url(signed.manifestHash, 32);
if (!manifestHash) throw new Error('对象密文清单格式无效');
return signExistingContentEvent({ spaceId: resource.id, channelId: object.channelId, domain: object.domain,
epoch: object.epoch, itemId, eventKind: object.domain === 'chat' ? 'chat.delete' : 'file.delete',
frameCount: signed.frameCount, manifestHash });
}
export async function verifyObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise<boolean> {
let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId);
if (!object?.deleteEvent?.signature) {
const refreshed = await readDataKeys(resource, session); state = refreshed.state;
object = state.objects.find(row => row.itemId === itemId);
}
const deleted = object?.deleteEvent;
if (resource.mode === 'raw' && !deleted?.signature) return true;
if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId
|| !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature
|| !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false;
const spaceId = fromBase64Url(resource.id, 16); const itemBytes = fromBase64Url(itemId, 16);
const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32);
const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64);
if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false;
const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey;
const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey;
const root = await importSigningPublicKey(rootJwk);
const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
if (object.authorAccount === local.username && local.profile.keys.signing
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
await pinSigningRoot(object.authorAccount, rootJwk);
if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false;
const domain = object.domain;
const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete';
if (deleted.kind !== expectedKind) return false;
const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount,
deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch,
itemId: itemBytes, frameCount: object.event.frameCount, manifestHash });
const deviceKey = await importSigningPublicKey(deviceJwk);
return verifyStatement(deviceKey, 'content.event', payload, signature);
}