Skip to content

Commit 0cc0ae2

Browse files
authored
chore: sign update messages before sending (#135)
1 parent 1d018f1 commit 0cc0ae2

File tree

12 files changed

+317
-81
lines changed

12 files changed

+317
-81
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 `updateId` 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+
"updateId" 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+
updateId String
8692
8793
@@id([spaceId, clock])
8894
}

apps/server/src/handlers/createUpdate.ts

Lines changed: 16 additions & 3 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+
updateId: 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+
updateId,
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 } } },
@@ -36,14 +46,17 @@ export const createUpdate = async ({ accountId, update, spaceId }: Params) => {
3646

3747
return await prisma.update.create({
3848
data: {
39-
spaceId,
49+
space: { connect: { id: spaceId } },
4050
clock,
4151
content: Buffer.from(update),
52+
signatureHex,
53+
signatureRecovery,
54+
updateId,
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+
updateId: update.updateId,
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: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -393,24 +393,49 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req
393393
break;
394394
}
395395
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));
396+
try {
397+
// Check that the update was signed by a valid identity
398+
// belonging to this accountId
399+
const signer = Messages.recoverUpdateMessageSigner(data);
400+
const identity = await getIdentity({ signaturePublicKey: signer });
401+
if (identity.accountId !== accountId) {
402+
throw new Error('Invalid signature');
403+
}
404+
const update = await createUpdate({
405+
accountId,
406+
spaceId: data.spaceId,
407+
update: data.update,
408+
signatureHex: data.signature.hex,
409+
signatureRecovery: data.signature.recovery,
410+
updateId: data.updateId,
411+
});
412+
const outgoingMessage: Messages.ResponseUpdateConfirmed = {
413+
type: 'update-confirmed',
414+
updateId: data.updateId,
415+
clock: update.clock,
416+
spaceId: data.spaceId,
417+
};
418+
webSocket.send(Messages.serialize(outgoingMessage));
404419

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-
});
420+
broadcastUpdates({
421+
spaceId: data.spaceId,
422+
updates: {
423+
updates: [
424+
{
425+
accountId,
426+
update: data.update,
427+
signature: data.signature,
428+
updateId: data.updateId,
429+
},
430+
],
431+
firstUpdateClock: update.clock,
432+
lastUpdateClock: update.clock,
433+
},
434+
currentClient: webSocket,
435+
});
436+
} catch (err) {
437+
console.error('Error creating update:', err);
438+
}
414439
break;
415440
}
416441
default:

packages/hypergraph-react/src/HypergraphAppContext.tsx

Lines changed: 71 additions & 50 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 type { 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 { getSessionNonce, identityExists, prepareSiweMessage } from '@graphprotocol/hypergraph/identity/login';
@@ -329,11 +330,66 @@ export function HypergraphAppProvider({
329330
// Handle WebSocket messages in a separate effect
330331
useEffect(() => {
331332
if (!websocketConnection) return;
333+
if (!accountId) {
334+
console.error('No accountId found');
335+
return;
336+
}
332337
const encryptionPrivateKey = keys?.encryptionPrivateKey;
333338
if (!encryptionPrivateKey) {
334339
console.error('No encryption private key found');
335340
return;
336341
}
342+
const signaturePrivateKey = keys?.signaturePrivateKey;
343+
if (!signaturePrivateKey) {
344+
console.error('No signature private key found.');
345+
return;
346+
}
347+
348+
const applyUpdates = async (
349+
spaceId: string,
350+
spaceSecretKey: string,
351+
automergeDocHandle: DocHandle<unknown>,
352+
updates: Messages.Updates,
353+
) => {
354+
const verifiedUpdates = await Promise.all(
355+
updates.updates.map(async (update) => {
356+
const signer = Messages.recoverUpdateMessageSigner({
357+
update: update.update,
358+
spaceId,
359+
updateId: update.updateId,
360+
signature: update.signature,
361+
accountId: update.accountId,
362+
});
363+
const authorIdentity = await getUserIdentity(update.accountId);
364+
if (authorIdentity.signaturePublicKey !== signer) {
365+
console.error(
366+
`Received invalid signature, recovered signer is ${signer},
367+
expected ${authorIdentity.signaturePublicKey}`,
368+
);
369+
return { valid: false, update: new Uint8Array([]) };
370+
}
371+
return {
372+
valid: true,
373+
update: Messages.decryptMessage({
374+
nonceAndCiphertext: update.update,
375+
secretKey: Utils.hexToBytes(spaceSecretKey),
376+
}),
377+
};
378+
}),
379+
);
380+
const validUpdates = verifiedUpdates.filter((update) => update.valid).map((update) => update.update);
381+
automergeDocHandle.update((existingDoc) => {
382+
const [newDoc] = automerge.applyChanges(existingDoc, validUpdates);
383+
return newDoc;
384+
});
385+
386+
store.send({
387+
type: 'applyUpdate',
388+
spaceId,
389+
firstUpdateClock: updates.firstUpdateClock,
390+
lastUpdateClock: updates.lastUpdateClock,
391+
});
392+
};
337393

338394
const onMessage = async (event: MessageEvent) => {
339395
const data = Messages.deserialize(event.data);
@@ -395,26 +451,7 @@ export function HypergraphAppProvider({
395451
}
396452

397453
if (response.updates) {
398-
const updates = response.updates?.updates.map((update) => {
399-
return Messages.decryptMessage({
400-
nonceAndCiphertext: update,
401-
secretKey: Utils.hexToBytes(keys[0].key),
402-
});
403-
});
404-
405-
for (const update of updates) {
406-
automergeDocHandle.update((existingDoc) => {
407-
const [newDoc] = automerge.applyChanges(existingDoc, [update]);
408-
return newDoc;
409-
});
410-
}
411-
412-
store.send({
413-
type: 'applyUpdate',
414-
spaceId: response.id,
415-
firstUpdateClock: response.updates?.firstUpdateClock,
416-
lastUpdateClock: response.updates?.lastUpdateClock,
417-
});
454+
await applyUpdates(response.id, keys[0].key, automergeDocHandle, response.updates);
418455
}
419456

420457
automergeDocHandle.on('change', (result) => {
@@ -427,19 +464,16 @@ export function HypergraphAppProvider({
427464
const storeState = store.getSnapshot();
428465
const space = storeState.context.spaces[0];
429466

430-
const ephemeralId = uuid();
467+
const updateId = uuid();
431468

432-
const nonceAndCiphertext = Messages.encryptMessage({
469+
const messageToSend = Messages.signedUpdateMessage({
470+
accountId,
471+
updateId,
472+
spaceId: space.id,
433473
message: lastLocalChange,
434-
secretKey: Utils.hexToBytes(space.keys[0].key),
474+
secretKey: space.keys[0].key,
475+
signaturePrivateKey,
435476
});
436-
437-
const messageToSend = {
438-
type: 'create-update',
439-
ephemeralId,
440-
update: nonceAndCiphertext,
441-
spaceId: space.id,
442-
} as const satisfies Messages.RequestCreateUpdate;
443477
websocketConnection.send(Messages.serialize(messageToSend));
444478
} catch (error) {
445479
console.error('Error sending message', error);
@@ -483,7 +517,7 @@ export function HypergraphAppProvider({
483517
case 'update-confirmed': {
484518
store.send({
485519
type: 'removeUpdateInFlight',
486-
ephemeralId: response.ephemeralId,
520+
updateId: response.updateId,
487521
});
488522
store.send({
489523
type: 'updateConfirmed',
@@ -500,25 +534,12 @@ export function HypergraphAppProvider({
500534
console.error('Space not found', response.spaceId);
501535
return;
502536
}
537+
if (!space.automergeDocHandle) {
538+
console.error('No automergeDocHandle found', response.spaceId);
539+
return;
540+
}
503541

504-
const automergeUpdates = response.updates.updates.map((update) => {
505-
return Messages.decryptMessage({
506-
nonceAndCiphertext: update,
507-
secretKey: Utils.hexToBytes(space.keys[0].key),
508-
});
509-
});
510-
511-
space?.automergeDocHandle?.update((existingDoc) => {
512-
const [newDoc] = automerge.applyChanges(existingDoc, automergeUpdates);
513-
return newDoc;
514-
});
515-
516-
store.send({
517-
type: 'applyUpdate',
518-
spaceId: response.spaceId,
519-
firstUpdateClock: response.updates.firstUpdateClock,
520-
lastUpdateClock: response.updates.lastUpdateClock,
521-
});
542+
await applyUpdates(response.spaceId, space.keys[0].key, space.automergeDocHandle, response.updates);
522543
break;
523544
}
524545
default: {
@@ -533,7 +554,7 @@ export function HypergraphAppProvider({
533554
return () => {
534555
websocketConnection.removeEventListener('message', onMessage);
535556
};
536-
}, [websocketConnection, spaces, keys?.encryptionPrivateKey]);
557+
}, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey]);
537558

538559
const createSpaceForContext = async () => {
539560
if (!accountId) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './decrypt-message.js';
22
export * from './encrypt-message.js';
33
export * from './serialize.js';
4+
export * from './signed-update-message.js';
45
export * from './types.js';

0 commit comments

Comments
 (0)