-
Notifications
You must be signed in to change notification settings - Fork 8
chore: sign update messages before sending #135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
723552d
73d3752
1cc6775
3eb3539
69f9010
fd4a555
25b94a3
5a99a8d
565be56
80ad482
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /* | ||
| Warnings: | ||
|
|
||
| - Added the required column `accountId` to the `Update` table without a default value. This is not possible if the table is not empty. | ||
| - Added the required column `ephemeralId` to the `Update` table without a default value. This is not possible if the table is not empty. | ||
| - Added the required column `signatureHex` to the `Update` table without a default value. This is not possible if the table is not empty. | ||
| - Added the required column `signatureRecovery` to the `Update` table without a default value. This is not possible if the table is not empty. | ||
|
|
||
| */ | ||
| -- RedefineTables | ||
| PRAGMA defer_foreign_keys=ON; | ||
| PRAGMA foreign_keys=OFF; | ||
| CREATE TABLE "new_Update" ( | ||
| "spaceId" TEXT NOT NULL, | ||
| "clock" INTEGER NOT NULL, | ||
| "content" BLOB NOT NULL, | ||
| "accountId" TEXT NOT NULL, | ||
| "signatureHex" TEXT NOT NULL, | ||
| "signatureRecovery" INTEGER NOT NULL, | ||
| "ephemeralId" TEXT NOT NULL, | ||
|
|
||
| PRIMARY KEY ("spaceId", "clock"), | ||
| CONSTRAINT "Update_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, | ||
| CONSTRAINT "Update_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE | ||
| ); | ||
| INSERT INTO "new_Update" ("clock", "content", "spaceId") SELECT "clock", "content", "spaceId" FROM "Update"; | ||
| DROP TABLE "Update"; | ||
| ALTER TABLE "new_Update" RENAME TO "Update"; | ||
| PRAGMA foreign_keys=ON; | ||
| PRAGMA defer_foreign_keys=OFF; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| # Please do not edit this file manually | ||
| # It should be added in your version-control system (i.e. Git) | ||
| # It should be added in your version-control system (e.g., Git) | ||
| provider = "sqlite" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { parse } from 'node:url'; | ||
| import { Identity, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph'; | ||
| import { recoverUpdateMessageSigner } from '@graphprotocol/hypergraph/messages/signed-update-message'; | ||
|
||
| import cors from 'cors'; | ||
| import { Effect, Exit, Schema } from 'effect'; | ||
| import express, { type Request, type Response } from 'express'; | ||
|
|
@@ -393,24 +394,49 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req | |
| break; | ||
| } | ||
| case 'create-update': { | ||
| const update = await createUpdate({ accountId, spaceId: data.spaceId, update: data.update }); | ||
| const outgoingMessage: Messages.ResponseUpdateConfirmed = { | ||
| type: 'update-confirmed', | ||
| ephemeralId: data.ephemeralId, | ||
| clock: update.clock, | ||
| spaceId: data.spaceId, | ||
| }; | ||
| webSocket.send(Messages.serialize(outgoingMessage)); | ||
| try { | ||
| // Check that the update was signed by a valid identity | ||
| // belonging to this accountId | ||
| const signer = recoverUpdateMessageSigner(data); | ||
| const identity = await getIdentity({ signaturePublicKey: signer }); | ||
| if (identity.accountId !== accountId) { | ||
| throw new Error('Invalid signature'); | ||
| } | ||
| const update = await createUpdate({ | ||
| accountId, | ||
| spaceId: data.spaceId, | ||
| update: data.update, | ||
| signatureHex: data.signature.hex, | ||
| signatureRecovery: data.signature.recovery, | ||
| ephemeralId: data.ephemeralId, | ||
| }); | ||
| const outgoingMessage: Messages.ResponseUpdateConfirmed = { | ||
| type: 'update-confirmed', | ||
| ephemeralId: data.ephemeralId, | ||
| clock: update.clock, | ||
| spaceId: data.spaceId, | ||
| }; | ||
| webSocket.send(Messages.serialize(outgoingMessage)); | ||
|
|
||
| broadcastUpdates({ | ||
| spaceId: data.spaceId, | ||
| updates: { | ||
| updates: [new Uint8Array(update.content)], | ||
| firstUpdateClock: update.clock, | ||
| lastUpdateClock: update.clock, | ||
| }, | ||
| currentClient: webSocket, | ||
| }); | ||
| broadcastUpdates({ | ||
| spaceId: data.spaceId, | ||
| updates: { | ||
| updates: [ | ||
| { | ||
| accountId, | ||
| update: data.update, | ||
| signature: data.signature, | ||
| ephemeralId: data.ephemeralId, | ||
| }, | ||
| ], | ||
| firstUpdateClock: update.clock, | ||
| lastUpdateClock: update.clock, | ||
| }, | ||
| currentClient: webSocket, | ||
| }); | ||
| } catch (err) { | ||
| console.error('Error creating update:', err); | ||
| } | ||
| break; | ||
| } | ||
| default: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import * as automerge from '@automerge/automerge'; | ||
| import { uuid } from '@automerge/automerge'; | ||
| import type { DocHandle } from '@automerge/automerge-repo'; | ||
| import { RepoContext } from '@automerge/automerge-repo-react-hooks'; | ||
| import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph'; | ||
| import { useSelector as useSelectorStore } from '@xstate/store/react'; | ||
|
|
@@ -464,11 +465,69 @@ export function HypergraphAppProvider({ | |
| // Handle WebSocket messages in a separate effect | ||
| useEffect(() => { | ||
| if (!websocketConnection) return; | ||
| if (!accountId) { | ||
| console.error('No accountId found'); | ||
| return; | ||
| } | ||
| const encryptionPrivateKey = keys?.encryptionPrivateKey; | ||
| if (!encryptionPrivateKey) { | ||
| console.error('No encryption private key found'); | ||
| return; | ||
| } | ||
| const signaturePrivateKey = keys?.signaturePrivateKey; | ||
| if (!signaturePrivateKey) { | ||
| console.error('No signature private key found.'); | ||
| return; | ||
| } | ||
|
|
||
| const applyUpdates = async ( | ||
| spaceId: string, | ||
| spaceSecretKey: string, | ||
| automergeDocHandle: DocHandle<unknown>, | ||
| updates: Messages.Updates, | ||
| ) => { | ||
| const verifiedUpdates = updates.updates.map(async (update) => { | ||
| const signer = Messages.recoverUpdateMessageSigner({ | ||
| update: update.update, | ||
| spaceId, | ||
| ephemeralId: update.ephemeralId, | ||
| signature: update.signature, | ||
| accountId: update.accountId, | ||
| }); | ||
| const authorIdentity = await getUserIdentity(update.accountId); | ||
| if (authorIdentity.signaturePublicKey !== signer) { | ||
| console.error( | ||
| `Received invalid signature, recovered signer is ${signer}, | ||
| expected ${authorIdentity.signaturePublicKey}`, | ||
| ); | ||
| return { valid: false, update: new Uint8Array([]) }; | ||
| } | ||
| return { | ||
| valid: true, | ||
| update: Messages.decryptMessage({ | ||
| nonceAndCiphertext: update.update, | ||
| secretKey: Utils.hexToBytes(spaceSecretKey), | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| for (const updatePromise of verifiedUpdates) { | ||
| const update = await updatePromise; | ||
| if (update.valid) { | ||
| automergeDocHandle.update((existingDoc) => { | ||
| const [newDoc] = automerge.applyChanges(existingDoc, [update.update]); | ||
| return newDoc; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| store.send({ | ||
| type: 'applyUpdate', | ||
| spaceId, | ||
| firstUpdateClock: updates.firstUpdateClock, | ||
| lastUpdateClock: updates.lastUpdateClock, | ||
| }); | ||
| }; | ||
|
|
||
| const onMessage = async (event: MessageEvent) => { | ||
| const data = Messages.deserialize(event.data); | ||
|
|
@@ -526,26 +585,7 @@ export function HypergraphAppProvider({ | |
| } | ||
|
|
||
| if (response.updates) { | ||
| const updates = response.updates?.updates.map((update) => { | ||
| return Messages.decryptMessage({ | ||
| nonceAndCiphertext: update, | ||
| secretKey: Utils.hexToBytes(keys[0].key), | ||
| }); | ||
| }); | ||
|
|
||
| for (const update of updates) { | ||
| automergeDocHandle.update((existingDoc) => { | ||
| const [newDoc] = automerge.applyChanges(existingDoc, [update]); | ||
| return newDoc; | ||
| }); | ||
| } | ||
|
|
||
| store.send({ | ||
| type: 'applyUpdate', | ||
| spaceId: response.id, | ||
| firstUpdateClock: response.updates?.firstUpdateClock, | ||
| lastUpdateClock: response.updates?.lastUpdateClock, | ||
| }); | ||
| await applyUpdates(response.id, keys[0].key, automergeDocHandle, response.updates); | ||
| } | ||
|
|
||
| automergeDocHandle.on('change', (result) => { | ||
|
|
@@ -560,17 +600,14 @@ export function HypergraphAppProvider({ | |
|
|
||
| const ephemeralId = uuid(); | ||
|
|
||
| const nonceAndCiphertext = Messages.encryptMessage({ | ||
| message: lastLocalChange, | ||
| secretKey: Utils.hexToBytes(space.keys[0].key), | ||
| }); | ||
|
|
||
| const messageToSend = { | ||
| type: 'create-update', | ||
| const messageToSend = Messages.signedUpdateMessage({ | ||
| accountId, | ||
| ephemeralId, | ||
| update: nonceAndCiphertext, | ||
| spaceId: space.id, | ||
| } as const satisfies Messages.RequestCreateUpdate; | ||
| message: lastLocalChange, | ||
| secretKey: space.keys[0].key, | ||
| signaturePrivateKey, | ||
| }); | ||
| websocketConnection.send(Messages.serialize(messageToSend)); | ||
| } catch (error) { | ||
| console.error('Error sending message', error); | ||
|
|
@@ -631,25 +668,12 @@ export function HypergraphAppProvider({ | |
| console.error('Space not found', response.spaceId); | ||
| return; | ||
| } | ||
| if (!space.automergeDocHandle) { | ||
| console.error('No automergeDocHandle found', response.spaceId); | ||
| return; | ||
| } | ||
|
|
||
| const automergeUpdates = response.updates.updates.map((update) => { | ||
| return Messages.decryptMessage({ | ||
| nonceAndCiphertext: update, | ||
| secretKey: Utils.hexToBytes(space.keys[0].key), | ||
| }); | ||
| }); | ||
|
|
||
| space?.automergeDocHandle?.update((existingDoc) => { | ||
| const [newDoc] = automerge.applyChanges(existingDoc, automergeUpdates); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here we gather all the automergeUpdates and then apply them. I actually didn't do it properly in the other case, but this should be the way to go. Can you update the function to do it this way? The reason is that afaik this is much more performant than applying ever update in a for loop
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense, will fix |
||
| return newDoc; | ||
| }); | ||
|
|
||
| store.send({ | ||
| type: 'applyUpdate', | ||
| spaceId: response.spaceId, | ||
| firstUpdateClock: response.updates.firstUpdateClock, | ||
| lastUpdateClock: response.updates.lastUpdateClock, | ||
| }); | ||
| await applyUpdates(response.spaceId, space.keys[0].key, space.automergeDocHandle, response.updates); | ||
| break; | ||
| } | ||
| default: { | ||
|
|
@@ -664,7 +688,7 @@ export function HypergraphAppProvider({ | |
| return () => { | ||
| websocketConnection.removeEventListener('message', onMessage); | ||
| }; | ||
| }, [websocketConnection, spaces, keys?.encryptionPrivateKey]); | ||
| }, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey]); | ||
|
|
||
| const createSpaceForContext = async () => { | ||
| if (!accountId) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| export * from './decrypt-message.js'; | ||
| export * from './encrypt-message.js'; | ||
| export * from './serialize.js'; | ||
| export * from './signed-update-message.js'; | ||
| export * from './types.js'; |
Uh oh!
There was an error while loading. Please reload this page.