Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions apps/server/src/handlers/applySpaceEvent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Effect, Exit } from 'effect';

import type { Messages } from '@graphprotocol/hypergraph';
import { SpaceEvents } from '@graphprotocol/hypergraph';
import { Identity, SpaceEvents } from '@graphprotocol/hypergraph';

import { prisma } from '../prisma.js';
import { getIdentity } from './getIdentity.js';

type Params = {
accountId: string;
Expand Down Expand Up @@ -36,7 +37,28 @@ export async function applySpaceEvent({ accountId, spaceId, event, keyBoxes }: P
orderBy: { counter: 'desc' },
});

const result = await Effect.runPromiseExit(SpaceEvents.applyEvent({ event, state: JSON.parse(lastEvent.state) }));
const getVerifiedIdentity = (accountIdToFetch: string) => {
// applySpaceEvent is only allowed to be called by the account that is applying the event
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess technically we could remove this check, right? It's assuming the behavior of applySpaceEvent, in the future we could have a case where we need to check the identity for a different account mentioned in the event. (That being said I think it's okay to leave this as it is and change it in the future if needed)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you are right that technically we could remove this check.

At the moment this ensures that the server only applies an event created that this account created. This must not be enforced, but I sleep a bit better that we enforce it. At least until we explicitly only allow accounts to send events to a sync server they themselves created

if (accountIdToFetch !== accountId) {
return Effect.fail(new Identity.InvalidIdentityError());
}

return Effect.gen(function* () {
const identity = yield* Effect.tryPromise({
try: () => getIdentity({ accountId: accountIdToFetch }),
catch: () => new Identity.InvalidIdentityError(),
});
return identity;
});
};

const result = await Effect.runPromiseExit(
SpaceEvents.applyEvent({
event,
state: JSON.parse(lastEvent.state),
getVerifiedIdentity,
}),
);
if (Exit.isFailure(result)) {
console.log('Failed to apply event', result);
throw new Error('Invalid event');
Expand Down
20 changes: 18 additions & 2 deletions apps/server/src/handlers/createSpace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { Effect, Exit } from 'effect';

import type { Messages } from '@graphprotocol/hypergraph';

import { SpaceEvents } from '@graphprotocol/hypergraph';
import { Identity, SpaceEvents } from '@graphprotocol/hypergraph';

import { prisma } from '../prisma.js';
import { getIdentity } from './getIdentity.js';

type Params = {
accountId: string;
Expand All @@ -14,7 +15,22 @@ type Params = {
};

export const createSpace = async ({ accountId, event, keyBox, keyId }: Params) => {
const result = await Effect.runPromiseExit(SpaceEvents.applyEvent({ event, state: undefined }));
const getVerifiedIdentity = (accountIdToFetch: string) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I see there's a few repetitions of this so maybe they could be split out into a utility function like this (in a separate file):

export const getGetVerifiedIdentity = (allowedAccountId: string) => {
  return function(accountIdToFetch: string) {
    // applySpaceEvent is only allowed to be called by the account that is applying the event
    if (accountIdToFetch !== allowedAccountId) {
      return Effect.fail(new Identity.InvalidIdentityError());
    }

    return Effect.gen(function* () {
      const identity = yield* Effect.tryPromise({
        try: () => getIdentity({ accountId: accountIdToFetch }),
        catch: () => new Identity.InvalidIdentityError(),
      });
      return identity;
    });
  }
}

Then you can pass getGetVerifiedIdentity(accountId) to applyEvent?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, will do it in a follow up PR 👍

// applySpaceEvent is only allowed to be called by the account that is applying the event
if (accountIdToFetch !== accountId) {
return Effect.fail(new Identity.InvalidIdentityError());
}

return Effect.gen(function* () {
const identity = yield* Effect.tryPromise({
try: () => getIdentity({ accountId: accountIdToFetch }),
catch: () => new Identity.InvalidIdentityError(),
});
return identity;
});
};

const result = await Effect.runPromiseExit(SpaceEvents.applyEvent({ event, state: undefined, getVerifiedIdentity }));
if (Exit.isFailure(result)) {
throw new Error('Invalid event');
}
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,26 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req
break;
}
case 'create-space-event': {
const getVerifiedIdentity = (accountIdToFetch: string) => {
if (accountIdToFetch !== accountId) {
return Effect.fail(new Identity.InvalidIdentityError());
}

return Effect.gen(function* () {
const identity = yield* Effect.tryPromise({
try: () => getIdentity({ accountId: accountIdToFetch }),
catch: () => new Identity.InvalidIdentityError(),
});
return identity;
});
};

const applyEventResult = await Effect.runPromiseExit(
SpaceEvents.applyEvent({ event: data.event, state: undefined }),
SpaceEvents.applyEvent({
event: data.event,
state: undefined,
getVerifiedIdentity,
}),
);
if (Exit.isSuccess(applyEventResult)) {
const space = await createSpace({ accountId, event: data.event, keyBox: data.keyBox, keyId: data.keyId });
Expand Down
117 changes: 47 additions & 70 deletions packages/hypergraph-react/src/HypergraphAppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type HypergraphAppCtx = {
acceptInvitation(params: Readonly<{ invitation: Messages.Invitation }>): Promise<unknown>;
subscribeToSpace(params: Readonly<{ spaceId: string }>): void;
inviteToSpace(params: Readonly<{ space: SpaceStorageEntry; invitee: { accountId: Address } }>): Promise<unknown>;
getUserIdentity(accountId: string): Promise<{
getVerifiedIdentity(accountId: string): Promise<{
accountId: string;
encryptionPublicKey: string;
signaturePublicKey: string;
Expand All @@ -44,28 +44,36 @@ export type HypergraphAppCtx = {
};

export const HypergraphAppContext = createContext<HypergraphAppCtx>({
async login() {},
logout() {},
setIdentityAndSessionToken() {},
async login() {
throw new Error('login is missing');
},
logout() {
throw new Error('logout is missing');
},
setIdentityAndSessionToken() {
throw new Error('setIdentityAndSessionToken is missing');
},
invitations: [],
async createSpace() {
return {};
throw new Error('createSpace is missing');
},
listSpaces() {
throw new Error('listSpaces is missing');
},
listInvitations() {
throw new Error('listInvitations is missing');
},
listSpaces() {},
listInvitations() {},
async acceptInvitation() {
return {};
throw new Error('acceptInvitation is missing');
},
subscribeToSpace() {
throw new Error('subscribeToSpace is missing');
},
subscribeToSpace() {},
async inviteToSpace() {
return {};
throw new Error('inviteToSpace is missing');
},
async getUserIdentity() {
return {
accountId: '',
encryptionPublicKey: '',
signaturePublicKey: '',
};
async getVerifiedIdentity() {
throw new Error('getVerifiedIdentity is missing');
},
loading: true,
});
Expand Down Expand Up @@ -294,7 +302,7 @@ export function HypergraphAppProvider({
signature: update.signature,
accountId: update.accountId,
});
const authorIdentity = await getUserIdentity(update.accountId);
const authorIdentity = await Identity.getVerifiedIdentity(update.accountId, syncServerUri);
if (authorIdentity.signaturePublicKey !== signer) {
console.error(
`Received invalid signature, recovered signer is ${signer},
Expand Down Expand Up @@ -325,6 +333,16 @@ export function HypergraphAppProvider({
});
};

const getVerifiedIdentity = (accountId: string) => {
return Effect.gen(function* () {
const identity = yield* Effect.tryPromise({
try: () => Identity.getVerifiedIdentity(accountId, syncServerUri),
catch: () => new Identity.InvalidIdentityError(),
});
return identity;
});
};

const onMessage = async (event: MessageEvent) => {
const data = Messages.deserialize(event.data);
const message = decodeResponseMessage(data);
Expand All @@ -346,7 +364,7 @@ export function HypergraphAppProvider({
for (const event of response.events) {
// Not sure why but type inference doesn't work here
const applyEventResult: Exit.Exit<SpaceEvents.SpaceState, SpaceEvents.ApplyError> =
await Effect.runPromiseExit(SpaceEvents.applyEvent({ state, event }));
await Effect.runPromiseExit(SpaceEvents.applyEvent({ state, event, getVerifiedIdentity }));
if (Exit.isSuccess(applyEventResult)) {
state = applyEventResult.value;
} else {
Expand Down Expand Up @@ -428,7 +446,7 @@ export function HypergraphAppProvider({
}

const applyEventResult = await Effect.runPromiseExit(
SpaceEvents.applyEvent({ event: response.event, state: space.state }),
SpaceEvents.applyEvent({ event: response.event, state: space.state, getVerifiedIdentity }),
);
if (Exit.isSuccess(applyEventResult)) {
store.send({
Expand Down Expand Up @@ -488,7 +506,7 @@ export function HypergraphAppProvider({
return () => {
websocketConnection.removeEventListener('message', onMessage);
};
}, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey]);
}, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey, syncServerUri]);

const createSpaceForContext = async () => {
if (!accountId) {
Expand Down Expand Up @@ -593,54 +611,6 @@ export function HypergraphAppProvider({
[websocketConnection],
);

const getUserIdentity = async (
accountId: string,
): Promise<{
accountId: string;
encryptionPublicKey: string;
signaturePublicKey: string;
}> => {
const storeState = store.getSnapshot();
const identity = storeState.context.userIdentities[accountId];
if (identity) {
return {
accountId,
encryptionPublicKey: identity.encryptionPublicKey,
signaturePublicKey: identity.signaturePublicKey,
};
}
const res = await fetch(`${syncServerUri}/identity?accountId=${accountId}`);
if (res.status !== 200) {
throw new Error('Failed to fetch identity');
}
const resDecoded = Schema.decodeUnknownSync(Messages.ResponseIdentity)(await res.json());

if (
!(await Identity.verifyIdentityOwnership(
resDecoded.accountId,
resDecoded.signaturePublicKey,
resDecoded.accountProof,
resDecoded.keyProof,
))
) {
throw new Error('Invalid identity');
}

store.send({
type: 'addUserIdentity',
accountId: resDecoded.accountId,
encryptionPublicKey: resDecoded.encryptionPublicKey,
signaturePublicKey: resDecoded.signaturePublicKey,
accountProof: resDecoded.accountProof,
keyProof: resDecoded.keyProof,
});
return {
accountId: resDecoded.accountId,
encryptionPublicKey: resDecoded.encryptionPublicKey,
signaturePublicKey: resDecoded.signaturePublicKey,
};
};

const inviteToSpace = async ({
space,
invitee,
Expand All @@ -667,7 +637,7 @@ export function HypergraphAppProvider({
console.error('No state found for space');
return;
}
const inviteeWithKeys = await getUserIdentity(invitee.accountId);
const inviteeWithKeys = await Identity.getVerifiedIdentity(invitee.accountId, syncServerUri);
const spaceEvent = await Effect.runPromiseExit(
SpaceEvents.createInvitation({
author: {
Expand Down Expand Up @@ -709,6 +679,13 @@ export function HypergraphAppProvider({
websocketConnection?.send(Messages.serialize(message));
};

const getVerifiedIdentity = useCallback(
(accountId: string) => {
return Identity.getVerifiedIdentity(accountId, syncServerUri);
},
[syncServerUri],
);

return (
<HypergraphAppContext.Provider
value={{
Expand All @@ -721,7 +698,7 @@ export function HypergraphAppProvider({
listInvitations,
acceptInvitation: acceptInvitationForContext,
subscribeToSpace,
getUserIdentity,
getVerifiedIdentity,
inviteToSpace,
loading,
}}
Expand Down
53 changes: 53 additions & 0 deletions packages/hypergraph/src/identity/get-verified-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as Schema from 'effect/Schema';
import * as Messages from '../messages/index.js';
import { store } from '../store.js';
import { verifyIdentityOwnership } from './prove-ownership.js';

export const getVerifiedIdentity = async (
accountId: string,
syncServerUri: string,
): Promise<{
accountId: string;
encryptionPublicKey: string;
signaturePublicKey: string;
}> => {
const storeState = store.getSnapshot();
const identity = storeState.context.identities[accountId];
if (identity) {
return {
accountId,
encryptionPublicKey: identity.encryptionPublicKey,
signaturePublicKey: identity.signaturePublicKey,
};
}
const res = await fetch(`${syncServerUri}/identity?accountId=${accountId}`);
if (res.status !== 200) {
throw new Error('Failed to fetch identity');
}
const resDecoded = Schema.decodeUnknownSync(Messages.ResponseIdentity)(await res.json());

if (
!(await verifyIdentityOwnership(
resDecoded.accountId,
resDecoded.signaturePublicKey,
resDecoded.accountProof,
resDecoded.keyProof,
))
) {
throw new Error('Invalid identity');
}

store.send({
type: 'addVerifiedIdentity',
accountId: resDecoded.accountId,
encryptionPublicKey: resDecoded.encryptionPublicKey,
signaturePublicKey: resDecoded.signaturePublicKey,
accountProof: resDecoded.accountProof,
keyProof: resDecoded.keyProof,
});
return {
accountId: resDecoded.accountId,
encryptionPublicKey: resDecoded.encryptionPublicKey,
signaturePublicKey: resDecoded.signaturePublicKey,
};
};
1 change: 1 addition & 0 deletions packages/hypergraph/src/identity/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './auth-storage.js';
export * from './create-identity-keys.js';
export * from './get-verified-identity.js';
export * from './identity-encryption.js';
export * from './login.js';
export * from './prove-ownership.js';
Expand Down
10 changes: 10 additions & 0 deletions packages/hypergraph/src/identity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ export type KeysSchema = Schema.Schema.Type<typeof KeysSchema>;
export type Identity = IdentityKeys & {
accountId: string;
};

export type PublicIdentity = {
accountId: string;
encryptionPublicKey: string;
signaturePublicKey: string;
};

export class InvalidIdentityError {
readonly _tag = 'InvalidIdentityError';
}
Loading
Loading