diff --git a/apps/events/src/routes/playground.tsx b/apps/events/src/routes/playground.tsx index 0d0dbb88..f82e394f 100644 --- a/apps/events/src/routes/playground.tsx +++ b/apps/events/src/routes/playground.tsx @@ -1,9 +1,10 @@ import { Button } from '@/components/ui/button'; import { assertExhaustive } from '@/lib/assertExhaustive'; import { createFileRoute } from '@tanstack/react-router'; +import { Effect } from 'effect'; import * as Schema from 'effect/Schema'; import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework'; -import { ResponseMessage, createIdentity, createSpace } from 'graph-framework'; +import { ResponseMessage, createSpace } from 'graph-framework'; import { useEffect, useState } from 'react'; const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage); @@ -12,7 +13,7 @@ export const Route = createFileRoute('/playground')({ component: () => , }); -const App = ({ accountId }: { accountId: string }) => { +const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => { const [websocketConnection, setWebsocketConnection] = useState(); const [spaces, setSpaces] = useState<{ id: string }[]>([]); @@ -75,9 +76,16 @@ const App = ({ accountId }: { accountId: string }) => { <>
- Account: {accountId ? accountId : 'none'} + Account: {account?.accountId ? account.accountId : 'none'}
- {accountId && ( + {account && ( )}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 017f4350..ae101014 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,11 +1,11 @@ import cors from 'cors'; import 'dotenv/config'; import { parse } from 'node:url'; -import { Schema } from 'effect'; +import { Effect, Exit, Schema } from 'effect'; import express from 'express'; import type { ResponseListSpaces, ResponseSpace } from 'graph-framework-messages'; import { RequestMessage } from 'graph-framework-messages'; -import type { CreateSpaceEvent } from 'graph-framework-space-events'; +import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events'; import type WebSocket from 'ws'; import { WebSocketServer } from 'ws'; import { createSpace } from './handlers/createSpace.js'; @@ -16,9 +16,9 @@ import { assertExhaustive } from './utils/assertExhaustive.js'; const decodeRequestMessage = Schema.decodeUnknownEither(RequestMessage); -tmpInitAccount('abc'); -tmpInitAccount('cde'); -tmpInitAccount('def'); +tmpInitAccount('0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5'); +tmpInitAccount('03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462'); +tmpInitAccount('0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2'); const webSocketServer = new WebSocketServer({ noServer: true }); const PORT = process.env.PORT !== undefined ? Number.parseInt(process.env.PORT) : 3030; @@ -70,14 +70,18 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request) case 'event': { switch (data.event.transaction.type) { case 'create-space': { - const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent }); - const spaceWithEvents = await getSpace({ accountId, spaceId: space.id }); - const outgoingMessage: ResponseSpace = { - type: 'space', - id: space.id, - events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)), - }; - webSocket.send(JSON.stringify(outgoingMessage)); + const applyEventResult = await Effect.runPromiseExit(applyEvent({ event: data.event })); + if (Exit.isSuccess(applyEventResult)) { + const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent }); + const spaceWithEvents = await getSpace({ accountId, spaceId: space.id }); + const outgoingMessage: ResponseSpace = { + type: 'space', + id: space.id, + events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)), + }; + webSocket.send(JSON.stringify(outgoingMessage)); + } + // TODO send back error break; } case 'delete-space': { diff --git a/packages/graph-framework-space-events/package.json b/packages/graph-framework-space-events/package.json index 98cd8035..ee713755 100644 --- a/packages/graph-framework-space-events/package.json +++ b/packages/graph-framework-space-events/package.json @@ -25,7 +25,8 @@ "effect": "^3.10.12" }, "dependencies": { - "uuid": "^11.0.2", - "graph-framework-utils": "workspace:*" + "@noble/curves": "^1.6.0", + "graph-framework-utils": "workspace:*", + "uuid": "^11.0.2" } } diff --git a/packages/graph-framework-space-events/src/apply-event.test.ts b/packages/graph-framework-space-events/src/apply-event.test.ts new file mode 100644 index 00000000..6f484ea9 --- /dev/null +++ b/packages/graph-framework-space-events/src/apply-event.test.ts @@ -0,0 +1,36 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Cause, Effect, Exit } from 'effect'; +import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; +import { expect, it } from 'vitest'; +import { applyEvent } from './apply-event.js'; +import { createSpace } from './create-space.js'; +import { VerifySignatureError } from './types.js'; + +it('should fail in case of an invalid signature', async () => { + const author = { + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', + encryptionPublicKey: 'encryption', + }; + + const result = await Effect.runPromiseExit( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + + const emptyTransaction = stringToUint8Array(canonicalize({})); + const signature = secp256k1.sign(emptyTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex(); + + // @ts-expect-error + spaceEvent.author.signature = signature; + return yield* applyEvent({ event: spaceEvent }); + }), + ); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = result.cause; + if (Cause.isFailType(cause)) { + expect(cause.error).toBeInstanceOf(VerifySignatureError); + } + } +}); diff --git a/packages/graph-framework-space-events/src/apply-event.ts b/packages/graph-framework-space-events/src/apply-event.ts index 6b8aad93..22f5896c 100644 --- a/packages/graph-framework-space-events/src/apply-event.ts +++ b/packages/graph-framework-space-events/src/apply-event.ts @@ -1,18 +1,35 @@ -import type { SpaceEvent, SpaceInvitation, SpaceMember, SpaceState } from './types.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Effect, Schema } from 'effect'; +import type { ParseError } from 'effect/ParseResult'; +import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; +import type { SpaceInvitation, SpaceMember, SpaceState } from './types.js'; +import { SpaceEvent, VerifySignatureError } from './types.js'; type Params = { state?: SpaceState; event: SpaceEvent; }; -export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => { - // TODO parse the event - const event = rawEvent; +const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent); - // TODO verify the event - // - verify the signature - // - verify that this event is based on the previous one - // - verify versioning +export const applyEvent = ({ + state, + event: rawEvent, +}: Params): Effect.Effect => { + const decodedEvent = decodeSpaceEvent(rawEvent); + if (decodedEvent._tag === 'Left') { + return decodedEvent.left; + } + const event = decodedEvent.right; + + const encodedTransaction = stringToUint8Array(canonicalize(event.transaction)); + const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, { + prehash: true, + }); + + if (!isValidSignature) { + return Effect.fail(new VerifySignatureError()); + } let id = ''; let members: { [signaturePublicKey: string]: SpaceMember } = {}; @@ -46,11 +63,11 @@ export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => { throw new Error('State is required for all events except create-space'); } - return { + return Effect.succeed({ id, members, removedMembers, invitations, transactionHash: '', // TODO - }; + }); }; diff --git a/packages/graph-framework-space-events/src/create-invitation.test.ts b/packages/graph-framework-space-events/src/create-invitation.test.ts index b946d281..cf15643a 100644 --- a/packages/graph-framework-space-events/src/create-invitation.test.ts +++ b/packages/graph-framework-space-events/src/create-invitation.test.ts @@ -1,20 +1,31 @@ import { expect, it } from 'vitest'; +import { Effect } from 'effect'; import { applyEvent } from './apply-event.js'; import { createInvitation } from './create-invitation.js'; import { createSpace } from './create-space.js'; -it('should create an invitation', () => { +it('should create an invitation', async () => { const author = { - signaturePublicKey: 'signature', + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', encryptionPublicKey: 'encryption', }; - const spaceEvent = createSpace({ author }); - const state = applyEvent({ event: spaceEvent }); - const spaceEvent2 = createInvitation({ author, id: state.id }); - const state2 = applyEvent({ state, event: spaceEvent2 }); + + const { spaceEvent2, state2 } = await Effect.runPromise( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + const state = yield* applyEvent({ event: spaceEvent }); + const spaceEvent2 = yield* createInvitation({ author, id: state.id }); + const state2 = yield* applyEvent({ state, event: spaceEvent2 }); + return { + state2, + spaceEvent2, + }; + }), + ); expect(state2).toEqual({ - id: state.id, + id: state2.id, members: { [author.signaturePublicKey]: { signaturePublicKey: author.signaturePublicKey, diff --git a/packages/graph-framework-space-events/src/create-invitation.ts b/packages/graph-framework-space-events/src/create-invitation.ts index 15bc4486..0b5642c1 100644 --- a/packages/graph-framework-space-events/src/create-invitation.ts +++ b/packages/graph-framework-space-events/src/create-invitation.ts @@ -1,3 +1,6 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Effect } from 'effect'; +import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; import type { Author, SpaceEvent } from './types.js'; type Params = { @@ -5,7 +8,7 @@ type Params = { id: string; }; -export const createInvitation = ({ author, id }: Params): SpaceEvent => { +export const createInvitation = ({ author, id }: Params): Effect.Effect => { const transaction = { type: 'create-invitation' as const, id, @@ -14,14 +17,14 @@ export const createInvitation = ({ author, id }: Params): SpaceEvent => { signaturePublicKey: '', encryptionPublicKey: '', }; - // TODO canonicalize, hash and sign the transaction - const signature = ''; + const encodedTransaction = stringToUint8Array(canonicalize(transaction)); + const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex(); - return { + return Effect.succeed({ transaction, author: { publicKey: author.signaturePublicKey, signature, }, - }; + }); }; diff --git a/packages/graph-framework-space-events/src/create-space.test.ts b/packages/graph-framework-space-events/src/create-space.test.ts index d26bb3c3..3fba1c04 100644 --- a/packages/graph-framework-space-events/src/create-space.test.ts +++ b/packages/graph-framework-space-events/src/create-space.test.ts @@ -1,17 +1,24 @@ +import { Effect } from 'effect'; import { expect, it } from 'vitest'; - import { applyEvent } from './apply-event.js'; import { createSpace } from './create-space.js'; -it('should create a space state', () => { +it('should create a space state', async () => { const author = { - signaturePublicKey: 'signature', + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', encryptionPublicKey: 'encryption', }; - const spaceEvent = createSpace({ author }); - const state = applyEvent({ event: spaceEvent }); + + const state = await Effect.runPromise( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + return yield* applyEvent({ event: spaceEvent }); + }), + ); + expect(state).toEqual({ - id: spaceEvent.transaction.id, + id: state.id, invitations: {}, members: { [author.signaturePublicKey]: { diff --git a/packages/graph-framework-space-events/src/create-space.ts b/packages/graph-framework-space-events/src/create-space.ts index 19837069..5b0fd907 100644 --- a/packages/graph-framework-space-events/src/create-space.ts +++ b/packages/graph-framework-space-events/src/create-space.ts @@ -1,26 +1,27 @@ -import { generateId } from 'graph-framework-utils'; - +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Effect } from 'effect'; +import { canonicalize, generateId, stringToUint8Array } from 'graph-framework-utils'; import type { Author, SpaceEvent } from './types.js'; type Params = { author: Author; }; -export const createSpace = ({ author }: Params): SpaceEvent => { +export const createSpace = ({ author }: Params): Effect.Effect => { const transaction = { type: 'create-space' as const, id: generateId(), creatorSignaturePublicKey: author.signaturePublicKey, creatorEncryptionPublicKey: author.encryptionPublicKey, }; - // TODO canonicalize, hash and sign the transaction - const signature = ''; + const encodedTransaction = stringToUint8Array(canonicalize(transaction)); + const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex(); - return { + return Effect.succeed({ transaction, author: { publicKey: author.signaturePublicKey, signature, }, - }; + }); }; diff --git a/packages/graph-framework-space-events/src/delete-space.test.ts b/packages/graph-framework-space-events/src/delete-space.test.ts index a146bfa9..318a6a88 100644 --- a/packages/graph-framework-space-events/src/delete-space.test.ts +++ b/packages/graph-framework-space-events/src/delete-space.test.ts @@ -1,19 +1,26 @@ +import { Effect } from 'effect'; import { expect, it } from 'vitest'; - import { applyEvent } from './apply-event.js'; import { createSpace } from './create-space.js'; import { deleteSpace } from './delete-space.js'; -it('should delete a space', () => { +it('should delete a space', async () => { const author = { - signaturePublicKey: 'signature', + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', encryptionPublicKey: 'encryption', }; - const spaceEvent = createSpace({ author }); - const state = applyEvent({ event: spaceEvent }); - const spaceEvent2 = deleteSpace({ author, id: state.id }); - const state2 = applyEvent({ state, event: spaceEvent2 }); - expect(state2).toEqual({ + + const state = await Effect.runPromise( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + const state = yield* applyEvent({ event: spaceEvent }); + const spaceEvent2 = yield* deleteSpace({ author, id: state.id }); + return yield* applyEvent({ state, event: spaceEvent2 }); + }), + ); + + expect(state).toEqual({ id: state.id, members: {}, invitations: {}, diff --git a/packages/graph-framework-space-events/src/delete-space.ts b/packages/graph-framework-space-events/src/delete-space.ts index f6b7e748..53b896a6 100644 --- a/packages/graph-framework-space-events/src/delete-space.ts +++ b/packages/graph-framework-space-events/src/delete-space.ts @@ -1,3 +1,6 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Effect } from 'effect'; +import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; import type { Author, SpaceEvent } from './types.js'; type Params = { @@ -5,19 +8,19 @@ type Params = { id: string; }; -export const deleteSpace = ({ author, id }: Params): SpaceEvent => { +export const deleteSpace = ({ author, id }: Params): Effect.Effect => { const transaction = { type: 'delete-space' as const, id, }; - // TODO canonicalize, hash and sign the transaction - const signature = ''; + const encodedTransaction = stringToUint8Array(canonicalize(transaction)); + const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex(); - return { + return Effect.succeed({ transaction, author: { publicKey: author.signaturePublicKey, signature, }, - }; + }); }; diff --git a/packages/graph-framework-space-events/src/types.ts b/packages/graph-framework-space-events/src/types.ts index 178a536e..412b34a0 100644 --- a/packages/graph-framework-space-events/src/types.ts +++ b/packages/graph-framework-space-events/src/types.ts @@ -76,7 +76,12 @@ export type SpaceEvent = Schema.Schema.Type; export const Author = Schema.Struct({ signaturePublicKey: Schema.String, + signaturePrivateKey: Schema.String, encryptionPublicKey: Schema.String, }); export type Author = Schema.Schema.Type; + +export class VerifySignatureError { + readonly _tag = 'VerifySignatureError'; +} diff --git a/packages/graph-framework-utils/src/index.ts b/packages/graph-framework-utils/src/index.ts index cf18bace..22c68f1e 100644 --- a/packages/graph-framework-utils/src/index.ts +++ b/packages/graph-framework-utils/src/index.ts @@ -1,3 +1,4 @@ export * from './base58.js'; export * from './generateId.js'; export * from './jsc.js'; +export * from './stringToUint8Array.js'; diff --git a/packages/graph-framework-utils/src/stringToUint8Array.test.ts b/packages/graph-framework-utils/src/stringToUint8Array.test.ts new file mode 100644 index 00000000..c969229a --- /dev/null +++ b/packages/graph-framework-utils/src/stringToUint8Array.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { stringToUint8Array } from './stringToUint8Array.js'; + +describe('stringToUint8Array', () => { + it('should convert a string to a Uint8Array', () => { + const encoded = stringToUint8Array('Hello World'); + expect(encoded).toEqual(new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])); + + const encoded2 = stringToUint8Array('{ "entry": "Hello World" }'); + expect(encoded2).toEqual( + new Uint8Array([ + 123, 32, 34, 101, 110, 116, 114, 121, 34, 58, 32, 34, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 34, + 32, 125, + ]), + ); + }); +}); diff --git a/packages/graph-framework-utils/src/stringToUint8Array.ts b/packages/graph-framework-utils/src/stringToUint8Array.ts new file mode 100644 index 00000000..a3340dd6 --- /dev/null +++ b/packages/graph-framework-utils/src/stringToUint8Array.ts @@ -0,0 +1,5 @@ +const encoder = new TextEncoder(); + +export const stringToUint8Array = (str: string): Uint8Array => { + return encoder.encode(str); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3a78bbe..92ae5742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,6 +314,9 @@ importers: packages/graph-framework-space-events: dependencies: + '@noble/curves': + specifier: ^1.6.0 + version: 1.6.0 graph-framework-utils: specifier: workspace:* version: link:../graph-framework-utils @@ -6605,7 +6608,7 @@ snapshots: '@scure/bip32@1.4.0': dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 @@ -6653,7 +6656,7 @@ snapshots: '@solana/wallet-standard-util@1.1.1': dependencies: - '@noble/curves': 1.2.0 + '@noble/curves': 1.6.0 '@solana/wallet-standard-chains': 1.1.0 '@solana/wallet-standard-features': 1.2.0 @@ -10653,7 +10656,7 @@ snapshots: webauthn-p256@0.0.5: dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.6.0 '@noble/hashes': 1.5.0 webidl-conversions@3.0.1: {}