272 lines
20 KiB
TypeScript
272 lines
20 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 runningKeys = new Map<string, Promise<DataKeys>>();
|
|
const queuedKeys = new Map<string, Promise<DataKeys>>();
|
|
function startKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
|
|
const task: Promise<DataKeys> = loadDataKeys(resource, session).finally(() => { if (runningKeys.get(resource.id) === task) runningKeys.delete(resource.id); });
|
|
runningKeys.set(resource.id, task);
|
|
return task;
|
|
}
|
|
/**
|
|
* 读取空间的加密状态。fresh(默认)表示调用方刚发现本地缺对象或事件签名,需要一份比现在更新的结果:
|
|
* 已有请求在途时不能直接借用它(它可能在新对象登记之前就发出了),而是排在它后面再请求一次,同时到达的调用合并成这一次。
|
|
* 不要求最新(首次打开)的调用可以直接借用在途请求。
|
|
*/
|
|
export function readDataKeys(resource: SavedResource, session: ChannelSession, fresh = true): Promise<DataKeys> {
|
|
const running = runningKeys.get(resource.id);
|
|
if (!running) return startKeys(resource, session);
|
|
if (!fresh) return running;
|
|
let next = queuedKeys.get(resource.id);
|
|
if (!next) {
|
|
next = running.catch(() => undefined).then(() => { queuedKeys.delete(resource.id); return startKeys(resource, session); });
|
|
queuedKeys.set(resource.id, next);
|
|
}
|
|
return next;
|
|
}
|
|
/** 内存里的结果满足 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 lockedThreads(resource: SavedResource, session: ChannelSession, channels: readonly { id: string; chatEpoch: number }[]): Promise<string[]> {
|
|
if (resource.mode !== 'enc') return [];
|
|
const owner = session.can('owner');
|
|
const missing = (keys: Record<string, string>) => channels.filter(c => !(owner && c.chatEpoch === 0) && !keys[domainId(c.id, 'chat', c.chatEpoch)]).map(c => c.id);
|
|
const { keys } = await cachedKeys(resource, session, data => missing(data.keys).length === 0);
|
|
return missing(keys);
|
|
}
|
|
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 (object && !signed?.signature) return {};
|
|
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;
|
|
// 没传完的上传从未发布过有效内容,删掉它不需要事件(见 signObjectDeletion)
|
|
if (object && !object.event?.signature && !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;
|
|
// 撤回别人的消息时(空间主),删除事件由撤回者签名,不是作者;旧记录没有 signerAccount,按作者处理
|
|
const signer = deleted.signerAccount || object.authorAccount;
|
|
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 (signer === 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(signer, rootJwk);
|
|
if (!await verifyDeviceCertificate(signer, 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: signer,
|
|
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);
|
|
}
|