diff --git a/apps/server/prisma/migrations/20250129220359_add_update_signature/migration.sql b/apps/server/prisma/migrations/20250129220359_add_update_signature/migration.sql new file mode 100644 index 00000000..9342d7e1 --- /dev/null +++ b/apps/server/prisma/migrations/20250129220359_add_update_signature/migration.sql @@ -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 `updateId` 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, + "updateId" 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; diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml index e5e5c470..e1640d1f 100644 --- a/apps/server/prisma/migrations/migration_lock.toml +++ b/apps/server/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 5b252fdc..1536ebfa 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -62,6 +62,7 @@ model Account { sessionNonce String? sessionToken String? sessionTokenExpires DateTime? + updates Update[] @@index([sessionToken]) } @@ -83,6 +84,11 @@ model Update { spaceId String clock Int content Bytes + account Account @relation(fields: [accountId], references: [id]) + accountId String + signatureHex String + signatureRecovery Int + updateId String @@id([spaceId, clock]) } diff --git a/apps/server/src/handlers/createUpdate.ts b/apps/server/src/handlers/createUpdate.ts index 8ee60a4f..bdf11b6b 100644 --- a/apps/server/src/handlers/createUpdate.ts +++ b/apps/server/src/handlers/createUpdate.ts @@ -4,9 +4,19 @@ type Params = { accountId: string; update: Uint8Array; spaceId: string; + signatureHex: string; + signatureRecovery: number; + updateId: string; }; -export const createUpdate = async ({ accountId, update, spaceId }: Params) => { +export const createUpdate = async ({ + accountId, + update, + spaceId, + signatureHex, + signatureRecovery, + updateId, +}: Params) => { // throw error if account is not a member of the space await prisma.space.findUniqueOrThrow({ where: { id: spaceId, members: { some: { id: accountId } } }, @@ -36,14 +46,17 @@ export const createUpdate = async ({ accountId, update, spaceId }: Params) => { return await prisma.update.create({ data: { - spaceId, + space: { connect: { id: spaceId } }, clock, content: Buffer.from(update), + signatureHex, + signatureRecovery, + updateId, + account: { connect: { id: accountId } }, }, }); }); success = true; - return result; } catch (error) { const dbError = error as { code?: string; message?: string }; if (dbError.code === 'P2034' || dbError.code === 'P1008' || dbError.message?.includes('database is locked')) { diff --git a/apps/server/src/handlers/getSpace.ts b/apps/server/src/handlers/getSpace.ts index 6c1d8a72..d570d3ce 100644 --- a/apps/server/src/handlers/getSpace.ts +++ b/apps/server/src/handlers/getSpace.ts @@ -53,6 +53,18 @@ export const getSpace = async ({ spaceId, accountId }: Params) => { }; }); + const formatUpdate = (update) => { + return { + accountId: update.accountId, + update: new Uint8Array(update.content), + signature: { + hex: update.signatureHex, + recovery: update.signatureRecovery, + }, + updateId: update.updateId, + }; + }; + return { id: space.id, events: space.events.map((wrapper) => JSON.parse(wrapper.event)), @@ -60,7 +72,7 @@ export const getSpace = async ({ spaceId, accountId }: Params) => { updates: space.updates.length > 0 ? { - updates: space.updates.map((update) => new Uint8Array(update.content)), + updates: space.updates.map(formatUpdate), firstUpdateClock: space.updates[0].clock, lastUpdateClock: space.updates[space.updates.length - 1].clock, } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f65acca5..07347409 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -393,24 +393,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 = Messages.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, + updateId: data.updateId, + }); + const outgoingMessage: Messages.ResponseUpdateConfirmed = { + type: 'update-confirmed', + updateId: data.updateId, + 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, + updateId: data.updateId, + }, + ], + firstUpdateClock: update.clock, + lastUpdateClock: update.clock, + }, + currentClient: webSocket, + }); + } catch (err) { + console.error('Error creating update:', err); + } break; } default: diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 7e5fb011..974b449d 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -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,66 @@ 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, + updates: Messages.Updates, + ) => { + const verifiedUpdates = await Promise.all( + updates.updates.map(async (update) => { + const signer = Messages.recoverUpdateMessageSigner({ + update: update.update, + spaceId, + updateId: update.updateId, + 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), + }), + }; + }), + ); + const validUpdates = verifiedUpdates.filter((update) => update.valid).map((update) => update.update); + automergeDocHandle.update((existingDoc) => { + const [newDoc] = automerge.applyChanges(existingDoc, validUpdates); + 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 +582,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) => { @@ -558,19 +595,16 @@ export function HypergraphAppProvider({ const storeState = store.getSnapshot(); const space = storeState.context.spaces[0]; - const ephemeralId = uuid(); + const updateId = uuid(); - const nonceAndCiphertext = Messages.encryptMessage({ + const messageToSend = Messages.signedUpdateMessage({ + accountId, + updateId, + spaceId: space.id, message: lastLocalChange, - secretKey: Utils.hexToBytes(space.keys[0].key), + secretKey: space.keys[0].key, + signaturePrivateKey, }); - - const messageToSend = { - type: 'create-update', - ephemeralId, - update: nonceAndCiphertext, - spaceId: space.id, - } as const satisfies Messages.RequestCreateUpdate; websocketConnection.send(Messages.serialize(messageToSend)); } catch (error) { console.error('Error sending message', error); @@ -614,7 +648,7 @@ export function HypergraphAppProvider({ case 'update-confirmed': { store.send({ type: 'removeUpdateInFlight', - ephemeralId: response.ephemeralId, + updateId: response.updateId, }); store.send({ type: 'updateConfirmed', @@ -631,25 +665,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); - 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 +685,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) { diff --git a/packages/hypergraph/src/messages/index.ts b/packages/hypergraph/src/messages/index.ts index 8ccc7d6e..714afee9 100644 --- a/packages/hypergraph/src/messages/index.ts +++ b/packages/hypergraph/src/messages/index.ts @@ -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'; diff --git a/packages/hypergraph/src/messages/signed-update-message.ts b/packages/hypergraph/src/messages/signed-update-message.ts new file mode 100644 index 00000000..ffbf7b4c --- /dev/null +++ b/packages/hypergraph/src/messages/signed-update-message.ts @@ -0,0 +1,84 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, canonicalize, hexToBytes, stringToUint8Array } from '../utils/index.js'; +import { encryptMessage } from './encrypt-message.js'; +import type { RequestCreateUpdate } from './types.js'; + +interface SignedMessageParams { + accountId: string; + updateId: string; + spaceId: string; + message: Uint8Array; + secretKey: string; + signaturePrivateKey: string; +} + +interface RecoverParams { + update: Uint8Array; + spaceId: string; + updateId: string; + signature: { + hex: string; + recovery: number; + }; + accountId: string; +} + +export const signedUpdateMessage = ({ + accountId, + updateId, + spaceId, + message, + secretKey, + signaturePrivateKey, +}: SignedMessageParams): RequestCreateUpdate => { + const update = encryptMessage({ + message, + secretKey: hexToBytes(secretKey), + }); + + const messageToSign = stringToUint8Array( + canonicalize({ + accountId, + updateId, + update, + spaceId, + }), + ); + + const recoverySignature = secp256k1.sign(messageToSign, hexToBytes(signaturePrivateKey), { prehash: true }); + + const signature = { + hex: recoverySignature.toCompactHex(), + recovery: recoverySignature.recovery, + }; + + return { + type: 'create-update', + updateId, + update, + spaceId, + accountId, + signature, + }; +}; + +export const recoverUpdateMessageSigner = ({ + update, + spaceId, + updateId, + signature, + accountId, +}: RecoverParams | RequestCreateUpdate) => { + const recoveredSignature = secp256k1.Signature.fromCompact(signature.hex).addRecoveryBit(signature.recovery); + const signedMessage = stringToUint8Array( + canonicalize({ + accountId, + updateId, + update, + spaceId, + }), + ); + const signedMessageHash = sha256(signedMessage); + return bytesToHex(recoveredSignature.recoverPublicKey(signedMessageHash).toRawBytes(true)); +}; diff --git a/packages/hypergraph/src/messages/types.ts b/packages/hypergraph/src/messages/types.ts index c505cc4b..406418b5 100644 --- a/packages/hypergraph/src/messages/types.ts +++ b/packages/hypergraph/src/messages/types.ts @@ -1,9 +1,17 @@ import * as Schema from 'effect/Schema'; import { AcceptInvitationEvent, CreateInvitationEvent, CreateSpaceEvent, SpaceEvent } from '../space-events/index.js'; +import { SignatureWithRecovery } from '../types.js'; + +export const SignedUpdate = Schema.Struct({ + update: Schema.Uint8Array, + accountId: Schema.String, + signature: SignatureWithRecovery, + updateId: Schema.String, +}); export const Updates = Schema.Struct({ - updates: Schema.Array(Schema.Uint8Array), + updates: Schema.Array(SignedUpdate), firstUpdateClock: Schema.Number, lastUpdateClock: Schema.Number, }); @@ -83,9 +91,11 @@ export type RequestListInvitations = Schema.Schema.Type; export const ResponseUpdateConfirmed = Schema.Struct({ type: Schema.Literal('update-confirmed'), - ephemeralId: Schema.String, + updateId: Schema.String, clock: Schema.Number, spaceId: Schema.String, }); diff --git a/packages/hypergraph/src/store.ts b/packages/hypergraph/src/store.ts index a46ceeef..9f2b44d0 100644 --- a/packages/hypergraph/src/store.ts +++ b/packages/hypergraph/src/store.ts @@ -50,8 +50,8 @@ const initialStoreContext: StoreContext = { type StoreEvent = | { type: 'setInvitations'; invitations: Invitation[] } | { type: 'reset' } - | { type: 'addUpdateInFlight'; ephemeralId: string } - | { type: 'removeUpdateInFlight'; ephemeralId: string } + | { type: 'addUpdateInFlight'; updateId: string } + | { type: 'removeUpdateInFlight'; updateId: string } | { type: 'setSpaceFromList'; spaceId: string } | { type: 'applyEvent'; spaceId: string; event: SpaceEvent; state: SpaceState } | { type: 'updateConfirmed'; spaceId: string; clock: number } @@ -99,16 +99,16 @@ export const store: Store = create reset: () => { return initialStoreContext; }, - addUpdateInFlight: (context, event: { ephemeralId: string }) => { + addUpdateInFlight: (context, event: { updateId: string }) => { return { ...context, - updatesInFlight: [...context.updatesInFlight, event.ephemeralId], + updatesInFlight: [...context.updatesInFlight, event.updateId], }; }, - removeUpdateInFlight: (context, event: { ephemeralId: string }) => { + removeUpdateInFlight: (context, event: { updateId: string }) => { return { ...context, - updatesInFlight: context.updatesInFlight.filter((id) => id !== event.ephemeralId), + updatesInFlight: context.updatesInFlight.filter((id) => id !== event.updateId), }; }, setSpaceFromList: (context, event: { spaceId: string }) => { diff --git a/packages/hypergraph/test/messages/signed-update-message.test.ts b/packages/hypergraph/test/messages/signed-update-message.test.ts new file mode 100644 index 00000000..5e3df735 --- /dev/null +++ b/packages/hypergraph/test/messages/signed-update-message.test.ts @@ -0,0 +1,34 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { randomBytes } from '@noble/hashes/utils'; +import { describe, expect, it } from 'vitest'; +import { recoverUpdateMessageSigner, signedUpdateMessage } from '../../src/messages/index.js'; +import { bytesToHex, hexToBytes } from '../../src/utils/index.js'; + +describe('sign updates and recover key', () => { + it('creates a signed message from which you can recover a signing key', () => { + const accountId = bytesToHex(randomBytes(20)); + const secretKey = bytesToHex(new Uint8Array(32).fill(1)); + const signaturePrivateKeyBytes = secp256k1.utils.randomPrivateKey(); + const signaturePrivateKey = bytesToHex(signaturePrivateKeyBytes); + const signaturePublicKey = bytesToHex(secp256k1.getPublicKey(signaturePrivateKeyBytes)); + const spaceId = '0x1234'; + const updateId = bytesToHex(randomBytes(32)); + + const message = hexToBytes('0x01234abcdef01234'); + + const msg = signedUpdateMessage({ + accountId, + updateId, + spaceId, + message, + secretKey, + signaturePrivateKey, + }); + + // The signer should be recoverable without needing anything + // outside of what's included in the message + const recoveredSigner = recoverUpdateMessageSigner(msg); + + expect(recoveredSigner).to.eq(signaturePublicKey); + }); +});