fix(web): verify deletions against the signer and read crypto state afresh
This commit is contained in:
1 parent
fc1dc08ebe
commit
6310f93469
2 files changed
+30
-11
No files matched your search
@@ -37,13 +37,29 @@ export function attachDataKeys(session: ChannelSession, resource: SavedResource)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
type DataKeys = { state: CryptoState; keys: Record<string, string> };
|
type DataKeys = { state: CryptoState; keys: Record<string, string> };
|
||||||
const pendingKeys = new Map<string, Promise<DataKeys>>();
|
const runningKeys = new Map<string, Promise<DataKeys>>();
|
||||||
/** 同一空间同时发起的读取合并成一次请求 */
|
const queuedKeys = new Map<string, Promise<DataKeys>>();
|
||||||
export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
|
function startKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
|
||||||
let task = pendingKeys.get(resource.id);
|
const task: Promise<DataKeys> = loadDataKeys(resource, session).finally(() => { if (runningKeys.get(resource.id) === task) runningKeys.delete(resource.id); });
|
||||||
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); }
|
runningKeys.set(resource.id, task);
|
||||||
return 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 就复用,不满足再请求 */
|
/** 内存里的结果满足 enough 就复用,不满足再请求 */
|
||||||
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
|
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
|
||||||
const have = loaded.get(resource.id);
|
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 eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32);
|
||||||
const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64);
|
const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64);
|
||||||
if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false;
|
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 rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey;
|
||||||
const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey;
|
const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey;
|
||||||
const root = await importSigningPublicKey(rootJwk);
|
const root = await importSigningPublicKey(rootJwk);
|
||||||
const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
|
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;
|
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
|
||||||
await pinSigningRoot(object.authorAccount, rootJwk);
|
await pinSigningRoot(signer, rootJwk);
|
||||||
if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false;
|
if (!await verifyDeviceCertificate(signer, deleted.signerDeviceId, deviceJwk, certificate, root)) return false;
|
||||||
const domain = object.domain;
|
const domain = object.domain;
|
||||||
const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete';
|
const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete';
|
||||||
if (deleted.kind !== expectedKind) return false;
|
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,
|
deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch,
|
||||||
itemId: itemBytes, frameCount: object.event.frameCount, manifestHash });
|
itemId: itemBytes, frameCount: object.event.frameCount, manifestHash });
|
||||||
const deviceKey = await importSigningPublicKey(deviceJwk);
|
const deviceKey = await importSigningPublicKey(deviceJwk);
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'removed':
|
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' });
|
showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -145,7 +146,7 @@ export async function syncChannel(resource: SavedResource, session: ChannelSessi
|
|||||||
clearFileCatalog(resource.id);
|
clearFileCatalog(resource.id);
|
||||||
readers.set(resource.id, session.createItemReader());
|
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);
|
keys?.catch(() => undefined);
|
||||||
let from = 0;
|
let from = 0;
|
||||||
for (let first = true; ; first = false) {
|
for (let first = true; ; first = false) {
|
||||||
|
|||||||
Reference in new issue
Block a user