fix(web): verify deletions against the signer and read crypto state afresh

This commit is contained in:
justme committed 2026-10-16 18:30:58 +08:00
1 parent fc1dc08ebe
commit 6310f93469
2 files changed
+30 -11

No files matched your search

+27 -9
View File
@@ -37,13 +37,29 @@ export function attachDataKeys(session: ChannelSession, resource: SavedResource)
};
}
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); }
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);
@@ -222,18 +238,20 @@ export async function verifyObjectDeletion(resource: SavedResource, session: Cha
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 (object.authorAccount === local.username && local.profile.keys.signing
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(object.authorAccount, rootJwk);
if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) 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: object.authorAccount,
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);
+3 -2
View File
@@ -110,7 +110,8 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
break;
}
case 'removed':
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) {
// 验签过程中的异常(例如签名公钥与已固定的不一致)按「拒绝这条记录」处理,不能让整条连接断开重连
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId).catch(() => false)) {
showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' });
break;
}
@@ -145,7 +146,7 @@ export async function syncChannel(resource: SavedResource, session: ChannelSessi
clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader());
// 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返
const keys = resource.mode === 'enc' ? readDataKeys(resource, session) : undefined;
const keys = resource.mode === 'enc' ? readDataKeys(resource, session, false) : undefined;
keys?.catch(() => undefined);
let from = 0;
for (let first = true; ; first = false) {