Skip to content

Commit 4c0ed11

Browse files
committed
sign events with the account and verify them before applying them
1 parent 85591bd commit 4c0ed11

File tree

16 files changed

+218
-79
lines changed

16 files changed

+218
-79
lines changed

apps/events/src/routes/playground.tsx

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Button } from '@/components/ui/button';
22
import { assertExhaustive } from '@/lib/assertExhaustive';
33
import { createFileRoute } from '@tanstack/react-router';
4+
import { Effect } from 'effect';
45
import * as Schema from 'effect/Schema';
56
import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework';
6-
import { ResponseMessage, createIdentity, createSpace } from 'graph-framework';
7+
import { ResponseMessage, createSpace } from 'graph-framework';
78
import { useEffect, useState } from 'react';
89

910
const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage);
@@ -12,7 +13,7 @@ export const Route = createFileRoute('/playground')({
1213
component: () => <ChooseAccount />,
1314
});
1415

15-
const App = ({ accountId }: { accountId: string }) => {
16+
const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => {
1617
const [websocketConnection, setWebsocketConnection] = useState<WebSocket>();
1718
const [spaces, setSpaces] = useState<{ id: string }[]>([]);
1819

@@ -75,9 +76,16 @@ const App = ({ accountId }: { accountId: string }) => {
7576
<>
7677
<div>
7778
<Button
78-
onClick={() => {
79-
const identity = createIdentity();
80-
const spaceEvent = createSpace({ author: identity });
79+
onClick={async () => {
80+
const spaceEvent = await Effect.runPromise(
81+
createSpace({
82+
author: {
83+
encryptionPublicKey: 'TODO',
84+
signaturePrivateKey,
85+
signaturePublicKey: accountId,
86+
},
87+
}),
88+
);
8189
const message: EventMessage = { type: 'event', event: spaceEvent };
8290
websocketConnection?.send(JSON.stringify(message));
8391
}}
@@ -117,39 +125,49 @@ const App = ({ accountId }: { accountId: string }) => {
117125
};
118126

119127
export const ChooseAccount = () => {
120-
const [accountId, setAccountId] = useState<string | null>();
128+
const [account, setAccount] = useState<{ accountId: string; signaturePrivateKey: string } | null>();
121129

122130
return (
123131
<div>
124132
<h1>Choose account</h1>
125133
<Button
126134
onClick={() => {
127-
setAccountId('abc');
135+
setAccount({
136+
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
137+
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
138+
});
128139
}}
129140
>
130141
`abc`
131142
</Button>
132143
<Button
133144
onClick={() => {
134-
setAccountId('cde');
145+
setAccount({
146+
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
147+
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
148+
});
135149
}}
136150
>
137151
`cde`
138152
</Button>
139153
<Button
140154
onClick={() => {
141-
setAccountId('def');
155+
setAccount({
156+
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
157+
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
158+
});
142159
}}
143160
>
144161
`def`
145162
</Button>
146-
Account: {accountId ? accountId : 'none'}
163+
Account: {account?.accountId ? account.accountId : 'none'}
147164
<hr />
148-
{accountId && (
165+
{account && (
149166
<App
150167
// forcing a remount of the App component when the accountId changes
151-
key={accountId}
152-
accountId={accountId}
168+
key={account.accountId}
169+
accountId={account.accountId}
170+
signaturePrivateKey={account.signaturePrivateKey}
153171
/>
154172
)}
155173
</div>

apps/server/src/index.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import cors from 'cors';
22
import 'dotenv/config';
33
import { parse } from 'node:url';
4-
import { Schema } from 'effect';
4+
import { Effect, Exit, Schema } from 'effect';
55
import express from 'express';
66
import type { ResponseListSpaces, ResponseSpace } from 'graph-framework-messages';
77
import { RequestMessage } from 'graph-framework-messages';
8-
import type { CreateSpaceEvent } from 'graph-framework-space-events';
8+
import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events';
99
import type WebSocket from 'ws';
1010
import { WebSocketServer } from 'ws';
1111
import { createSpace } from './handlers/createSpace.js';
@@ -16,9 +16,9 @@ import { assertExhaustive } from './utils/assertExhaustive.js';
1616

1717
const decodeRequestMessage = Schema.decodeUnknownEither(RequestMessage);
1818

19-
tmpInitAccount('abc');
20-
tmpInitAccount('cde');
21-
tmpInitAccount('def');
19+
tmpInitAccount('0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5');
20+
tmpInitAccount('03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462');
21+
tmpInitAccount('0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2');
2222

2323
const webSocketServer = new WebSocketServer({ noServer: true });
2424
const PORT = process.env.PORT !== undefined ? Number.parseInt(process.env.PORT) : 3030;
@@ -70,14 +70,18 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request)
7070
case 'event': {
7171
switch (data.event.transaction.type) {
7272
case 'create-space': {
73-
const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent });
74-
const spaceWithEvents = await getSpace({ accountId, spaceId: space.id });
75-
const outgoingMessage: ResponseSpace = {
76-
type: 'space',
77-
id: space.id,
78-
events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)),
79-
};
80-
webSocket.send(JSON.stringify(outgoingMessage));
73+
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event: data.event }));
74+
if (Exit.isSuccess(applyEventResult)) {
75+
const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent });
76+
const spaceWithEvents = await getSpace({ accountId, spaceId: space.id });
77+
const outgoingMessage: ResponseSpace = {
78+
type: 'space',
79+
id: space.id,
80+
events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)),
81+
};
82+
webSocket.send(JSON.stringify(outgoingMessage));
83+
}
84+
// TODO send back error
8185
break;
8286
}
8387
case 'delete-space': {

packages/graph-framework-space-events/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"effect": "^3.10.12"
2626
},
2727
"dependencies": {
28-
"uuid": "^11.0.2",
29-
"graph-framework-utils": "workspace:*"
28+
"@noble/curves": "^1.6.0",
29+
"graph-framework-utils": "workspace:*",
30+
"uuid": "^11.0.2"
3031
}
3132
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { secp256k1 } from '@noble/curves/secp256k1';
2+
import { Cause, Effect, Exit } from 'effect';
3+
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
4+
import { expect, it } from 'vitest';
5+
import { applyEvent } from './apply-event.js';
6+
import { createSpace } from './create-space.js';
7+
import { VerifySignatureError } from './types.js';
8+
9+
it('should fail in case of an invalid signature', async () => {
10+
const author = {
11+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
12+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
13+
encryptionPublicKey: 'encryption',
14+
};
15+
16+
const result = await Effect.runPromiseExit(
17+
Effect.gen(function* () {
18+
const spaceEvent = yield* createSpace({ author });
19+
20+
const emptyTransaction = stringToUint8Array(canonicalize({}));
21+
const signature = secp256k1.sign(emptyTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();
22+
23+
// @ts-expect-error
24+
spaceEvent.author.signature = signature;
25+
return yield* applyEvent({ event: spaceEvent });
26+
}),
27+
);
28+
29+
expect(Exit.isFailure(result)).toBe(true);
30+
if (Exit.isFailure(result)) {
31+
const cause = result.cause;
32+
if (Cause.isFailType(cause)) {
33+
expect(cause.error).toBeInstanceOf(VerifySignatureError);
34+
}
35+
}
36+
});

packages/graph-framework-space-events/src/apply-event.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
1-
import type { SpaceEvent, SpaceInvitation, SpaceMember, SpaceState } from './types.js';
1+
import { secp256k1 } from '@noble/curves/secp256k1';
2+
import { Effect, Schema } from 'effect';
3+
import type { ParseError } from 'effect/ParseResult';
4+
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
5+
import type { SpaceInvitation, SpaceMember, SpaceState } from './types.js';
6+
import { SpaceEvent, VerifySignatureError } from './types.js';
27

38
type Params = {
49
state?: SpaceState;
510
event: SpaceEvent;
611
};
712

8-
export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => {
9-
// TODO parse the event
10-
const event = rawEvent;
13+
const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent);
1114

12-
// TODO verify the event
13-
// - verify the signature
14-
// - verify that this event is based on the previous one
15-
// - verify versioning
15+
export const applyEvent = ({
16+
state,
17+
event: rawEvent,
18+
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError> => {
19+
const decodedEvent = decodeSpaceEvent(rawEvent);
20+
if (decodedEvent._tag === 'Left') {
21+
return decodedEvent.left;
22+
}
23+
const event = decodedEvent.right;
24+
25+
const encodedTransaction = stringToUint8Array(canonicalize(event.transaction));
26+
const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, {
27+
prehash: true,
28+
});
29+
30+
if (!isValidSignature) {
31+
return Effect.fail(new VerifySignatureError());
32+
}
1633

1734
let id = '';
1835
let members: { [signaturePublicKey: string]: SpaceMember } = {};
@@ -46,11 +63,11 @@ export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => {
4663
throw new Error('State is required for all events except create-space');
4764
}
4865

49-
return {
66+
return Effect.succeed({
5067
id,
5168
members,
5269
removedMembers,
5370
invitations,
5471
transactionHash: '', // TODO
55-
};
72+
});
5673
};

packages/graph-framework-space-events/src/create-invitation.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
import { expect, it } from 'vitest';
22

3+
import { Effect } from 'effect';
34
import { applyEvent } from './apply-event.js';
45
import { createInvitation } from './create-invitation.js';
56
import { createSpace } from './create-space.js';
67

7-
it('should create an invitation', () => {
8+
it('should create an invitation', async () => {
89
const author = {
9-
signaturePublicKey: 'signature',
10+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
11+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
1012
encryptionPublicKey: 'encryption',
1113
};
12-
const spaceEvent = createSpace({ author });
13-
const state = applyEvent({ event: spaceEvent });
14-
const spaceEvent2 = createInvitation({ author, id: state.id });
15-
const state2 = applyEvent({ state, event: spaceEvent2 });
14+
15+
const { spaceEvent2, state2 } = await Effect.runPromise(
16+
Effect.gen(function* () {
17+
const spaceEvent = yield* createSpace({ author });
18+
const state = yield* applyEvent({ event: spaceEvent });
19+
const spaceEvent2 = yield* createInvitation({ author, id: state.id });
20+
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
21+
return {
22+
state2,
23+
spaceEvent2,
24+
};
25+
}),
26+
);
1627
expect(state2).toEqual({
17-
id: state.id,
28+
id: state2.id,
1829
members: {
1930
[author.signaturePublicKey]: {
2031
signaturePublicKey: author.signaturePublicKey,
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { secp256k1 } from '@noble/curves/secp256k1';
2+
import { Effect } from 'effect';
3+
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
14
import type { Author, SpaceEvent } from './types.js';
25

36
type Params = {
47
author: Author;
58
id: string;
69
};
710

8-
export const createInvitation = ({ author, id }: Params): SpaceEvent => {
11+
export const createInvitation = ({ author, id }: Params): Effect.Effect<SpaceEvent, undefined> => {
912
const transaction = {
1013
type: 'create-invitation' as const,
1114
id,
@@ -14,14 +17,14 @@ export const createInvitation = ({ author, id }: Params): SpaceEvent => {
1417
signaturePublicKey: '',
1518
encryptionPublicKey: '',
1619
};
17-
// TODO canonicalize, hash and sign the transaction
18-
const signature = '';
20+
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
21+
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();
1922

20-
return {
23+
return Effect.succeed({
2124
transaction,
2225
author: {
2326
publicKey: author.signaturePublicKey,
2427
signature,
2528
},
26-
};
29+
});
2730
};

packages/graph-framework-space-events/src/create-space.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1+
import { Effect } from 'effect';
12
import { expect, it } from 'vitest';
2-
33
import { applyEvent } from './apply-event.js';
44
import { createSpace } from './create-space.js';
55

6-
it('should create a space state', () => {
6+
it('should create a space state', async () => {
77
const author = {
8-
signaturePublicKey: 'signature',
8+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
9+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
910
encryptionPublicKey: 'encryption',
1011
};
11-
const spaceEvent = createSpace({ author });
12-
const state = applyEvent({ event: spaceEvent });
12+
13+
const state = await Effect.runPromise(
14+
Effect.gen(function* () {
15+
const spaceEvent = yield* createSpace({ author });
16+
return yield* applyEvent({ event: spaceEvent });
17+
}),
18+
);
19+
1320
expect(state).toEqual({
14-
id: spaceEvent.transaction.id,
21+
id: state.id,
1522
invitations: {},
1623
members: {
1724
[author.signaturePublicKey]: {
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1-
import { generateId } from 'graph-framework-utils';
2-
1+
import { secp256k1 } from '@noble/curves/secp256k1';
2+
import { Effect } from 'effect';
3+
import { canonicalize, generateId, stringToUint8Array } from 'graph-framework-utils';
34
import type { Author, SpaceEvent } from './types.js';
45

56
type Params = {
67
author: Author;
78
};
89

9-
export const createSpace = ({ author }: Params): SpaceEvent => {
10+
export const createSpace = ({ author }: Params): Effect.Effect<SpaceEvent, undefined> => {
1011
const transaction = {
1112
type: 'create-space' as const,
1213
id: generateId(),
1314
creatorSignaturePublicKey: author.signaturePublicKey,
1415
creatorEncryptionPublicKey: author.encryptionPublicKey,
1516
};
16-
// TODO canonicalize, hash and sign the transaction
17-
const signature = '';
17+
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
18+
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();
1819

19-
return {
20+
return Effect.succeed({
2021
transaction,
2122
author: {
2223
publicKey: author.signaturePublicKey,
2324
signature,
2425
},
25-
};
26+
});
2627
};

0 commit comments

Comments
 (0)