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.
This commit is contained in:
justme committed 2026-10-16 06:00:16 +08:00
1 parent 593eeba18e
commit d77ca1f113
2 files changed
+23 -6

No files matched your search

+19 -6
View File
@@ -5,7 +5,7 @@ import { apiFetch } from './http';
import { accountPublicKey, accountToken, requireAccount } from './accountSession';
import { decryptInvitation, encryptInvitation } from './accountCrypto';
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
import { loadWorkspace } from './workspaceStructure';
import { cachedWorkspace, loadWorkspace } from './workspaceStructure';
import { pinSigningRoot } from './accountPersistence';
import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning';
@@ -13,12 +13,17 @@ 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}`;
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); }
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;
const { keys } = await readDataKeys(resource, session);
// 内存里已有这个对象和它的数据域密钥就直接用;否则才重新拉(每次都拉整份 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)];
@@ -35,6 +40,11 @@ export function readDataKeys(resource: SavedResource, session: ChannelSession):
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);
@@ -49,7 +59,9 @@ async function loadDataKeys(resource: SavedResource, session: ChannelSession): P
keys[id] = value.key;
}
if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys });
return { state, 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;
@@ -102,7 +114,8 @@ export async function initializeChannelKeys(resource: SavedResource, session: Ch
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);
const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId);
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);
@@ -111,7 +124,7 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
let keyEnvelope = '';
if (resource.mode === 'enc') {
const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)];
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);
@@ -24,6 +24,10 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
publishWorkspace(id, { t: 'structure', state: body.structure, people });
return body;
}
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
return current.get(id) ?? loadWorkspace(id);
}
export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);