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(); const states = new Map(); 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(); 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 }; const pendingKeys = new Map>(); /** 同一空间同时发起的读取合并成一次请求 */ export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise { 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 { const have = loaded.get(resource.id); return have && enough(have) ? have : readDataKeys(resource, session); } async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise { const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise); 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 { const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise)).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 { 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 { 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 { 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); 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 { 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 { 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> { 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 { 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); }