217 lines
16 KiB
TypeScript
217 lines
16 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 { 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}`;
|
|
export function clearDataKeys(): void { objectKeys.clear(); states.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);
|
|
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;
|
|
};
|
|
}
|
|
export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record<string, string> }> {
|
|
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 });
|
|
return { state, keys };
|
|
}
|
|
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);
|
|
const { structure } = await loadWorkspace(resource.id); let channel = 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 readDataKeys(resource, session); 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);
|
|
}
|