Messages from other members or devices used to render as an unknown member until the space structure refreshed. Use the author account the server records for the object, together with the loaded profiles, when the item arrives.
242 lines
18 KiB
TypeScript
242 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 objectAuthorAccount(resourceId: string, itemId: string): string | undefined {
|
|
return states.get(resourceId)?.objects.find(o => o.itemId === itemId)?.authorAccount ?? undefined;
|
|
}
|
|
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);
|
|
}
|