Skip to content

Commit 25b94a3

Browse files
committed
chore: verify updates on server and client
1 parent fd4a555 commit 25b94a3

File tree

7 files changed

+164
-82
lines changed

7 files changed

+164
-82
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `accountId` to the `Update` table without a default value. This is not possible if the table is not empty.
5+
- Added the required column `ephemeralId` to the `Update` table without a default value. This is not possible if the table is not empty.
6+
- Added the required column `signatureHex` to the `Update` table without a default value. This is not possible if the table is not empty.
7+
- Added the required column `signatureRecovery` to the `Update` table without a default value. This is not possible if the table is not empty.
8+
9+
*/
10+
-- RedefineTables
11+
PRAGMA defer_foreign_keys=ON;
12+
PRAGMA foreign_keys=OFF;
13+
CREATE TABLE "new_Update" (
14+
"spaceId" TEXT NOT NULL,
15+
"clock" INTEGER NOT NULL,
16+
"content" BLOB NOT NULL,
17+
"accountId" TEXT NOT NULL,
18+
"signatureHex" TEXT NOT NULL,
19+
"signatureRecovery" INTEGER NOT NULL,
20+
"ephemeralId" TEXT NOT NULL,
21+
22+
PRIMARY KEY ("spaceId", "clock"),
23+
CONSTRAINT "Update_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
24+
CONSTRAINT "Update_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
25+
);
26+
INSERT INTO "new_Update" ("clock", "content", "spaceId") SELECT "clock", "content", "spaceId" FROM "Update";
27+
DROP TABLE "Update";
28+
ALTER TABLE "new_Update" RENAME TO "Update";
29+
PRAGMA foreign_keys=ON;
30+
PRAGMA defer_foreign_keys=OFF;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
2-
# It should be added in your version-control system (i.e. Git)
2+
# It should be added in your version-control system (e.g., Git)
33
provider = "sqlite"

apps/server/prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ model Account {
6262
sessionNonce String?
6363
sessionToken String?
6464
sessionTokenExpires DateTime?
65+
updates Update[]
6566
6667
@@index([sessionToken])
6768
}
@@ -83,6 +84,11 @@ model Update {
8384
spaceId String
8485
clock Int
8586
content Bytes
87+
account Account @relation(fields: [accountId], references: [id])
88+
accountId String
89+
signatureHex String
90+
signatureRecovery Int
91+
ephemeralId String
8692
8793
@@id([spaceId, clock])
8894
}

apps/server/src/handlers/createUpdate.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@ type Params = {
44
accountId: string;
55
update: Uint8Array;
66
spaceId: string;
7+
signatureHex: string;
8+
signatureRecovery: number;
9+
ephemeralId: string;
710
};
811

9-
export const createUpdate = async ({ accountId, update, spaceId }: Params) => {
12+
export const createUpdate = async ({
13+
accountId,
14+
update,
15+
spaceId,
16+
signatureHex,
17+
signatureRecovery,
18+
ephemeralId,
19+
}: Params) => {
1020
// throw error if account is not a member of the space
1121
await prisma.space.findUniqueOrThrow({
1222
where: { id: spaceId, members: { some: { id: accountId } } },
@@ -39,11 +49,14 @@ export const createUpdate = async ({ accountId, update, spaceId }: Params) => {
3949
spaceId,
4050
clock,
4151
content: Buffer.from(update),
52+
signatureHex,
53+
signatureRecovery,
54+
ephemeralId,
55+
account: { connect: { id: accountId } },
4256
},
4357
});
4458
});
4559
success = true;
46-
return result;
4760
} catch (error) {
4861
const dbError = error as { code?: string; message?: string };
4962
if (dbError.code === 'P2034' || dbError.code === 'P1008' || dbError.message?.includes('database is locked')) {

apps/server/src/handlers/getSpace.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,26 @@ export const getSpace = async ({ spaceId, accountId }: Params) => {
5353
};
5454
});
5555

56+
const formatUpdate = (update) => {
57+
return {
58+
accountId: update.accountId,
59+
update: new Uint8Array(update.content),
60+
signature: {
61+
hex: update.signatureHex,
62+
recovery: update.signatureRecovery,
63+
},
64+
ephemeralId: update.ephemeralId,
65+
};
66+
};
67+
5668
return {
5769
id: space.id,
5870
events: space.events.map((wrapper) => JSON.parse(wrapper.event)),
5971
keyBoxes,
6072
updates:
6173
space.updates.length > 0
6274
? {
63-
updates: space.updates.map((update) => new Uint8Array(update.content)),
75+
updates: space.updates.map(formatUpdate),
6476
firstUpdateClock: space.updates[0].clock,
6577
lastUpdateClock: space.updates[space.updates.length - 1].clock,
6678
}

apps/server/src/index.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parse } from 'node:url';
22
import { Identity, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph';
3+
import { recoverUpdateMessageSigner } from '@graphprotocol/hypergraph/messages/signed-update-message';
34
import cors from 'cors';
45
import { Effect, Exit, Schema } from 'effect';
56
import express, { type Request, type Response } from 'express';
@@ -393,24 +394,49 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req
393394
break;
394395
}
395396
case 'create-update': {
396-
const update = await createUpdate({ accountId, spaceId: data.spaceId, update: data.update });
397-
const outgoingMessage: Messages.ResponseUpdateConfirmed = {
398-
type: 'update-confirmed',
399-
ephemeralId: data.ephemeralId,
400-
clock: update.clock,
401-
spaceId: data.spaceId,
402-
};
403-
webSocket.send(Messages.serialize(outgoingMessage));
397+
try {
398+
// Check that the update was signed by a valid identity
399+
// belonging to this accountId
400+
const signer = recoverUpdateMessageSigner(data);
401+
const identity = await getIdentity({ signaturePublicKey: signer });
402+
if (identity.accountId !== accountId) {
403+
throw new Error('Invalid signature');
404+
}
405+
const update = await createUpdate({
406+
accountId,
407+
spaceId: data.spaceId,
408+
update: data.update,
409+
signatureHex: data.signature.hex,
410+
signatureRecovery: data.signature.recovery,
411+
ephemeralId: data.ephemeralId,
412+
});
413+
const outgoingMessage: Messages.ResponseUpdateConfirmed = {
414+
type: 'update-confirmed',
415+
ephemeralId: data.ephemeralId,
416+
clock: update.clock,
417+
spaceId: data.spaceId,
418+
};
419+
webSocket.send(Messages.serialize(outgoingMessage));
404420

405-
broadcastUpdates({
406-
spaceId: data.spaceId,
407-
updates: {
408-
updates: [new Uint8Array(update.content)],
409-
firstUpdateClock: update.clock,
410-
lastUpdateClock: update.clock,
411-
},
412-
currentClient: webSocket,
413-
});
421+
broadcastUpdates({
422+
spaceId: data.spaceId,
423+
updates: {
424+
updates: [
425+
{
426+
accountId,
427+
update: data.update,
428+
signature: data.signature,
429+
ephemeralId: data.ephemeralId,
430+
},
431+
],
432+
firstUpdateClock: update.clock,
433+
lastUpdateClock: update.clock,
434+
},
435+
currentClient: webSocket,
436+
});
437+
} catch (err) {
438+
console.error('Error creating update:', err);
439+
}
414440
break;
415441
}
416442
default:

packages/hypergraph-react/src/HypergraphAppContext.tsx

Lines changed: 56 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import * as automerge from '@automerge/automerge';
44
import { uuid } from '@automerge/automerge';
5+
import { DocHandle } from '@automerge/automerge-repo';
56
import { RepoContext } from '@automerge/automerge-repo-react-hooks';
67
import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph';
78
import { useSelector as useSelectorStore } from '@xstate/store/react';
@@ -479,6 +480,55 @@ export function HypergraphAppProvider({
479480
return;
480481
}
481482

483+
const applyUpdates = async (
484+
spaceId: string,
485+
spaceSecretKey: string,
486+
automergeDocHandle: DocHandle<unknown>,
487+
updates: Messages.Updates,
488+
) => {
489+
const verifiedUpdates = updates.updates.map(async (update) => {
490+
const signer = Messages.recoverUpdateMessageSigner({
491+
update: update.update,
492+
spaceId,
493+
ephemeralId: update.ephemeralId,
494+
signature: update.signature,
495+
accountId: update.accountId,
496+
});
497+
const authorIdentity = await getUserIdentity(update.accountId);
498+
if (authorIdentity.signaturePublicKey !== signer) {
499+
console.error(
500+
`Received invalid signature, recovered signer is ${signer},
501+
expected ${authorIdentity.signaturePublicKey}`,
502+
);
503+
return { valid: false, update: new Uint8Array([]) };
504+
}
505+
return {
506+
valid: true,
507+
update: Messages.decryptMessage({
508+
nonceAndCiphertext: update.update,
509+
secretKey: Utils.hexToBytes(spaceSecretKey),
510+
}),
511+
};
512+
});
513+
514+
for (const updatePromise of verifiedUpdates) {
515+
const update = await updatePromise;
516+
if (update.valid) {
517+
automergeDocHandle.update((existingDoc) => {
518+
const [newDoc] = automerge.applyChanges(existingDoc, [update.update]);
519+
return newDoc;
520+
});
521+
}
522+
}
523+
524+
store.send({
525+
type: 'applyUpdate',
526+
spaceId,
527+
firstUpdateClock: updates.firstUpdateClock,
528+
lastUpdateClock: updates.lastUpdateClock,
529+
});
530+
};
531+
482532
const onMessage = async (event: MessageEvent) => {
483533
const data = Messages.deserialize(event.data);
484534
const message = decodeResponseMessage(data);
@@ -535,49 +585,7 @@ export function HypergraphAppProvider({
535585
}
536586

537587
if (response.updates) {
538-
const updates = response.updates?.updates.map(async (update) => {
539-
// TODO verify the update signature and that the signing key
540-
// belongs to the reported accountId
541-
const signer = Messages.recoverUpdateMessageSigner({
542-
update: update.update,
543-
spaceId: response.id,
544-
ephemeralId: update.ephemeralId,
545-
signature: update.signature,
546-
accountId: update.accountId,
547-
});
548-
const authorIdentity = await getUserIdentity(update.accountId);
549-
if (authorIdentity.signaturePublicKey !== signer) {
550-
console.error(
551-
`Received invalid signature, recovered signer is ${signer},
552-
expected ${authorIdentity.signaturePublicKey}`,
553-
);
554-
return { valid: false, update: new Uint8Array([]) };
555-
}
556-
return {
557-
valid: true,
558-
update: Messages.decryptMessage({
559-
nonceAndCiphertext: update.update,
560-
secretKey: Utils.hexToBytes(keys[0].key),
561-
}),
562-
};
563-
});
564-
565-
for (const updatePromise of updates) {
566-
const update = await updatePromise;
567-
if (update.valid) {
568-
automergeDocHandle.update((existingDoc) => {
569-
const [newDoc] = automerge.applyChanges(existingDoc, [update.update]);
570-
return newDoc;
571-
});
572-
}
573-
}
574-
575-
store.send({
576-
type: 'applyUpdate',
577-
spaceId: response.id,
578-
firstUpdateClock: response.updates?.firstUpdateClock,
579-
lastUpdateClock: response.updates?.lastUpdateClock,
580-
});
588+
await applyUpdates(response.id, keys[0].key, automergeDocHandle, response.updates);
581589
}
582590

583591
automergeDocHandle.on('change', (result) => {
@@ -660,25 +668,12 @@ export function HypergraphAppProvider({
660668
console.error('Space not found', response.spaceId);
661669
return;
662670
}
671+
if (!space.automergeDocHandle) {
672+
console.error('No automergeDocHandle found', response.spaceId);
673+
return;
674+
}
663675

664-
const automergeUpdates = response.updates.updates.map((update) => {
665-
return Messages.decryptMessage({
666-
nonceAndCiphertext: update,
667-
secretKey: Utils.hexToBytes(space.keys[0].key),
668-
});
669-
});
670-
671-
space?.automergeDocHandle?.update((existingDoc) => {
672-
const [newDoc] = automerge.applyChanges(existingDoc, automergeUpdates);
673-
return newDoc;
674-
});
675-
676-
store.send({
677-
type: 'applyUpdate',
678-
spaceId: response.spaceId,
679-
firstUpdateClock: response.updates.firstUpdateClock,
680-
lastUpdateClock: response.updates.lastUpdateClock,
681-
});
676+
await applyUpdates(response.spaceId, space.keys[0].key, space.automergeDocHandle, response.updates);
682677
break;
683678
}
684679
default: {

0 commit comments

Comments
 (0)