Skip to content

Commit 5e8c433

Browse files
committed
verify space event identities
1 parent 5618129 commit 5e8c433

File tree

11 files changed

+203
-106
lines changed

11 files changed

+203
-106
lines changed

apps/server/src/handlers/applySpaceEvent.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Messages } from '@graphprotocol/hypergraph';
44
import { SpaceEvents } from '@graphprotocol/hypergraph';
55

66
import { prisma } from '../prisma.js';
7+
import { getIdentity } from './getIdentity.js';
78

89
type Params = {
910
accountId: string;
@@ -36,7 +37,15 @@ export async function applySpaceEvent({ accountId, spaceId, event, keyBoxes }: P
3637
orderBy: { counter: 'desc' },
3738
});
3839

39-
const result = await Effect.runPromiseExit(SpaceEvents.applyEvent({ event, state: JSON.parse(lastEvent.state) }));
40+
const identity = await getIdentity({ accountId });
41+
42+
const result = await Effect.runPromiseExit(
43+
SpaceEvents.applyEvent({
44+
event,
45+
state: JSON.parse(lastEvent.state),
46+
getVerifiedIdentity: () => Effect.succeed(identity),
47+
}),
48+
);
4049
if (Exit.isFailure(result)) {
4150
console.log('Failed to apply event', result);
4251
throw new Error('Invalid event');

apps/server/src/handlers/createSpace.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Messages } from '@graphprotocol/hypergraph';
55
import { SpaceEvents } from '@graphprotocol/hypergraph';
66

77
import { prisma } from '../prisma.js';
8+
import { getIdentity } from './getIdentity.js';
89

910
type Params = {
1011
accountId: string;
@@ -14,7 +15,10 @@ type Params = {
1415
};
1516

1617
export const createSpace = async ({ accountId, event, keyBox, keyId }: Params) => {
17-
const result = await Effect.runPromiseExit(SpaceEvents.applyEvent({ event, state: undefined }));
18+
const identity = await getIdentity({ accountId });
19+
const result = await Effect.runPromiseExit(
20+
SpaceEvents.applyEvent({ event, state: undefined, getVerifiedIdentity: () => Effect.succeed(identity) }),
21+
);
1822
if (Exit.isFailure(result)) {
1923
throw new Error('Invalid event');
2024
}

apps/server/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,13 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req
331331
break;
332332
}
333333
case 'create-space-event': {
334+
const identity = await getIdentity({ accountId });
334335
const applyEventResult = await Effect.runPromiseExit(
335-
SpaceEvents.applyEvent({ event: data.event, state: undefined }),
336+
SpaceEvents.applyEvent({
337+
event: data.event,
338+
state: undefined,
339+
getVerifiedIdentity: () => Effect.succeed(identity),
340+
}),
336341
);
337342
if (Exit.isSuccess(applyEventResult)) {
338343
const space = await createSpace({ accountId, event: data.event, keyBox: data.keyBox, keyId: data.keyId });

packages/hypergraph-react/src/HypergraphAppContext.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
restoreKeys,
1212
signup,
1313
} from '@graphprotocol/hypergraph/identity/login';
14+
import { InvalidIdentityError } from '@graphprotocol/hypergraph/identity/types';
1415
import { useSelector as useSelectorStore } from '@xstate/store/react';
1516
import { Effect, Exit } from 'effect';
1617
import * as Schema from 'effect/Schema';
@@ -400,6 +401,16 @@ export function HypergraphAppProvider({
400401
return;
401402
}
402403

404+
const getVerifiedIdentity = (accountId: string) => {
405+
return Effect.gen(function* () {
406+
const identity = yield* Effect.tryPromise({
407+
try: () => Identity.getVerifiedIdentity(accountId, syncServerUri),
408+
catch: () => new InvalidIdentityError(),
409+
});
410+
return identity;
411+
});
412+
};
413+
403414
const onMessage = async (event: MessageEvent) => {
404415
const data = Messages.deserialize(event.data);
405416
const message = decodeResponseMessage(data);
@@ -419,7 +430,9 @@ export function HypergraphAppProvider({
419430
let state: SpaceEvents.SpaceState | undefined = undefined;
420431

421432
for (const event of response.events) {
422-
const applyEventResult = await Effect.runPromiseExit(SpaceEvents.applyEvent({ state: undefined, event }));
433+
const applyEventResult = await Effect.runPromiseExit(
434+
SpaceEvents.applyEvent({ state: undefined, event, getVerifiedIdentity }),
435+
);
423436
if (Exit.isSuccess(applyEventResult)) {
424437
state = applyEventResult.value;
425438
}
@@ -521,7 +534,7 @@ export function HypergraphAppProvider({
521534
}
522535

523536
const applyEventResult = await Effect.runPromiseExit(
524-
SpaceEvents.applyEvent({ event: response.event, state: space.state }),
537+
SpaceEvents.applyEvent({ event: response.event, state: space.state, getVerifiedIdentity }),
525538
);
526539
if (Exit.isSuccess(applyEventResult)) {
527540
store.send({
@@ -594,7 +607,7 @@ export function HypergraphAppProvider({
594607
return () => {
595608
websocketConnection.removeEventListener('message', onMessage);
596609
};
597-
}, [websocketConnection, spaces, keys?.encryptionPrivateKey]);
610+
}, [websocketConnection, spaces, keys?.encryptionPrivateKey, syncServerUri]);
598611

599612
const createSpaceForContext = async () => {
600613
if (!accountId) {

packages/hypergraph/src/identity/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,13 @@ export type KeysSchema = Schema.Schema.Type<typeof KeysSchema>;
3232
export type Identity = IdentityKeys & {
3333
accountId: string;
3434
};
35+
36+
export type PublicIdentity = {
37+
accountId: string;
38+
encryptionPublicKey: string;
39+
signaturePublicKey: string;
40+
};
41+
42+
export class InvalidIdentityError {
43+
readonly _tag = 'InvalidIdentityError';
44+
}

packages/hypergraph/src/space-events/apply-event.ts

Lines changed: 78 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { secp256k1 } from '@noble/curves/secp256k1';
22
import { sha256 } from '@noble/hashes/sha256';
33
import { Effect, Schema } from 'effect';
44
import type { ParseError } from 'effect/ParseResult';
5+
import type { InvalidIdentityError, PublicIdentity } from '../identity/types.js';
56
import { canonicalize, stringToUint8Array } from '../utils/index.js';
67
import { hashEvent } from './hash-event.js';
78
import {
@@ -16,14 +17,16 @@ import {
1617
type Params = {
1718
state: SpaceState | undefined;
1819
event: SpaceEvent;
20+
getVerifiedIdentity: (accountId: string) => Effect.Effect<PublicIdentity, InvalidIdentityError>;
1921
};
2022

2123
const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent);
2224

2325
export const applyEvent = ({
2426
state,
2527
event: rawEvent,
26-
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError | InvalidEventError> => {
28+
getVerifiedIdentity,
29+
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError | InvalidEventError | InvalidIdentityError> => {
2730
const decodedEvent = decodeSpaceEvent(rawEvent);
2831
if (decodedEvent._tag === 'Left') {
2932
return decodedEvent.left;
@@ -43,90 +46,92 @@ export const applyEvent = ({
4346

4447
let signatureInstance = secp256k1.Signature.fromCompact(event.author.signature.hex);
4548
signatureInstance = signatureInstance.addRecoveryBit(event.author.signature.recovery);
46-
// @ts-expect-error
47-
const authorPublicKey = signatureInstance.recoverPublicKey(sha256(encodedTransaction));
48-
// TODO compare it to the public key from the author accountId (this already verifies the signature)
49-
// in case of a failure we return Effect.fail(new VerifySignatureError());
50-
51-
// biome-ignore lint/correctness/noConstantCondition: wip
52-
if (false) {
53-
return Effect.fail(new VerifySignatureError());
54-
}
55-
56-
let id = '';
57-
let members: { [accountId: string]: SpaceMember } = {};
58-
let removedMembers: { [accountId: string]: SpaceMember } = {};
59-
let invitations: { [id: string]: SpaceInvitation } = {};
49+
const authorPublicKey = `0x${signatureInstance.recoverPublicKey(sha256(encodedTransaction)).toHex()}`;
6050

61-
if (event.transaction.type === 'create-space') {
62-
id = event.transaction.id;
63-
members[event.transaction.creatorAccountId] = {
64-
accountId: event.transaction.creatorAccountId,
65-
role: 'admin',
66-
};
67-
} else if (state !== undefined) {
68-
id = state.id;
69-
members = { ...state.members };
70-
removedMembers = { ...state.removedMembers };
71-
invitations = { ...state.invitations };
72-
73-
if (event.transaction.type === 'accept-invitation') {
74-
// is already a member
75-
if (members[event.author.accountId] !== undefined) {
76-
return Effect.fail(new InvalidEventError());
77-
}
51+
return Effect.gen(function* () {
52+
const identity = yield* getVerifiedIdentity(event.author.accountId);
53+
if (authorPublicKey !== identity.signaturePublicKey) {
54+
yield* Effect.fail(new VerifySignatureError());
55+
}
7856

79-
// find the invitation
80-
const result = Object.entries(invitations).find(
81-
([, invitation]) => invitation.inviteeAccountId === event.author.accountId,
82-
);
83-
if (!result) {
84-
return Effect.fail(new InvalidEventError());
85-
}
86-
const [id, invitation] = result;
57+
let id = '';
58+
let members: { [accountId: string]: SpaceMember } = {};
59+
let removedMembers: { [accountId: string]: SpaceMember } = {};
60+
let invitations: { [id: string]: SpaceInvitation } = {};
8761

88-
members[invitation.inviteeAccountId] = {
89-
accountId: invitation.inviteeAccountId,
90-
role: 'member',
62+
if (event.transaction.type === 'create-space') {
63+
id = event.transaction.id;
64+
members[event.transaction.creatorAccountId] = {
65+
accountId: event.transaction.creatorAccountId,
66+
role: 'admin',
9167
};
92-
delete invitations[id];
93-
if (removedMembers[event.author.accountId] !== undefined) {
94-
delete removedMembers[event.author.accountId];
95-
}
96-
} else {
97-
// check if the author is an admin
98-
if (members[event.author.accountId]?.role !== 'admin') {
99-
return Effect.fail(new InvalidEventError());
100-
}
68+
} else if (state !== undefined) {
69+
id = state.id;
70+
members = { ...state.members };
71+
removedMembers = { ...state.removedMembers };
72+
invitations = { ...state.invitations };
10173

102-
if (event.transaction.type === 'delete-space') {
103-
removedMembers = { ...members };
104-
members = {};
105-
invitations = {};
106-
} else if (event.transaction.type === 'create-invitation') {
107-
if (members[event.transaction.inviteeAccountId] !== undefined) {
108-
return Effect.fail(new InvalidEventError());
74+
if (event.transaction.type === 'accept-invitation') {
75+
// is already a member
76+
if (members[event.author.accountId] !== undefined) {
77+
yield* Effect.fail(new InvalidEventError());
10978
}
110-
for (const invitation of Object.values(invitations)) {
111-
if (invitation.inviteeAccountId === event.transaction.inviteeAccountId) {
112-
return Effect.fail(new InvalidEventError());
113-
}
79+
80+
// find the invitation
81+
const result = Object.entries(invitations).find(
82+
([, invitation]) => invitation.inviteeAccountId === event.author.accountId,
83+
);
84+
if (!result) {
85+
yield* Effect.fail(new InvalidEventError());
11486
}
11587

116-
invitations[event.transaction.id] = {
117-
inviteeAccountId: event.transaction.inviteeAccountId,
88+
// @ts-expect-error type issue? we checked that result is not undefined before
89+
const [id, invitation] = result;
90+
91+
members[invitation.inviteeAccountId] = {
92+
accountId: invitation.inviteeAccountId,
93+
role: 'member',
11894
};
95+
delete invitations[id];
96+
if (removedMembers[event.author.accountId] !== undefined) {
97+
delete removedMembers[event.author.accountId];
98+
}
11999
} else {
120-
throw new Error('State is required for all events except create-space');
100+
// check if the author is an admin
101+
if (members[event.author.accountId]?.role !== 'admin') {
102+
yield* Effect.fail(new InvalidEventError());
103+
}
104+
105+
if (event.transaction.type === 'delete-space') {
106+
removedMembers = { ...members };
107+
members = {};
108+
invitations = {};
109+
} else if (event.transaction.type === 'create-invitation') {
110+
if (members[event.transaction.inviteeAccountId] !== undefined) {
111+
yield* Effect.fail(new InvalidEventError());
112+
}
113+
for (const invitation of Object.values(invitations)) {
114+
if (invitation.inviteeAccountId === event.transaction.inviteeAccountId) {
115+
yield* Effect.fail(new InvalidEventError());
116+
}
117+
}
118+
119+
invitations[event.transaction.id] = {
120+
inviteeAccountId: event.transaction.inviteeAccountId,
121+
};
122+
} else {
123+
// state is required for all events except create-space
124+
yield* Effect.fail(new InvalidEventError());
125+
}
121126
}
122127
}
123-
}
124128

125-
return Effect.succeed({
126-
id,
127-
members,
128-
removedMembers,
129-
invitations,
130-
lastEventHash: hashEvent(event),
129+
return {
130+
id,
131+
members,
132+
removedMembers,
133+
invitations,
134+
lastEventHash: hashEvent(event),
135+
};
131136
});
132137
};

packages/hypergraph/test/space-events/accept-invitation.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,29 @@ const invitee = {
2020
encryptionPublicKey: 'encryption',
2121
};
2222

23+
const getVerifiedIdentity = (accountId: string) => {
24+
if (accountId === author.accountId) {
25+
return Effect.succeed(author);
26+
}
27+
return Effect.succeed(invitee);
28+
};
29+
2330
it('should accept an invitation', async () => {
2431
const { state3 } = await Effect.runPromise(
2532
Effect.gen(function* () {
2633
const spaceEvent = yield* createSpace({ author });
27-
const state = yield* applyEvent({ event: spaceEvent, state: undefined });
34+
const state = yield* applyEvent({ event: spaceEvent, state: undefined, getVerifiedIdentity });
2835
const spaceEvent2 = yield* createInvitation({
2936
author,
3037
previousEventHash: state.lastEventHash,
3138
invitee,
3239
});
33-
const state2 = yield* applyEvent({ event: spaceEvent2, state });
40+
const state2 = yield* applyEvent({ event: spaceEvent2, state, getVerifiedIdentity });
3441
const spaceEvent3 = yield* acceptInvitation({
3542
previousEventHash: state2.lastEventHash,
3643
author: invitee,
3744
});
38-
const state3 = yield* applyEvent({ event: spaceEvent3, state: state2 });
45+
const state3 = yield* applyEvent({ event: spaceEvent3, state: state2, getVerifiedIdentity });
3946
return {
4047
state3,
4148
spaceEvent3,

0 commit comments

Comments
 (0)