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
134 changes: 28 additions & 106 deletions packages/hypergraph-react/src/HypergraphAppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import * as automerge from '@automerge/automerge';
import { uuid } from '@automerge/automerge';
import { RepoContext } from '@automerge/automerge-repo-react-hooks';
import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph';
import {
getSessionNonce,
identityExists,
prepareSiweMessage,
restoreKeys,
signup,
} from '@graphprotocol/hypergraph/identity/login';
import { useSelector as useSelectorStore } from '@xstate/store/react';
import { Effect, Exit } from 'effect';
import * as Schema from 'effect/Schema';
Expand All @@ -17,7 +24,6 @@ import {
useRef,
useState,
} from 'react';
import { SiweMessage } from 'siwe';
import type { Hex } from 'viem';
import { type Address, getAddress } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
Expand Down Expand Up @@ -127,45 +133,17 @@ export function HypergraphAppProvider({
const sessionToken = useSelectorStore(store, (state) => state.context.sessionToken);
const keys = useSelectorStore(store, (state) => state.context.keys);

function prepareSiweMessage(address: Address, nonce: string) {
return new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to Hypergraph',
uri: window.location.origin,
version: '1',
chainId,
nonce,
expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
}).prepareMessage();
}

async function getSessionNonce(accountId: string) {
const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce;
const res = await fetch(new URL('/login/nonce', syncServerUri), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(nonceReq),
});
const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json());
return decoded.sessionNonce;
}

async function identityExists(accountId: string) {
const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), {
method: 'GET',
});
return res.status === 200;
}

async function loginWithWallet(signer: Identity.Signer, accountId: Address, retryCount = 0) {
const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
if (!sessionToken) {
const sessionNonce = await getSessionNonce(accountId);
const sessionNonce = await getSessionNonce(accountId, syncServerUri);
// Use SIWE to login with the server and get a token
const message = prepareSiweMessage(accountId, sessionNonce);
const message = prepareSiweMessage(
accountId,
sessionNonce,
{ host: window.location.host, origin: window.location.origin },
chainId,
);
const signature = await signer.signMessage(message);
const loginReq = { accountId, message, signature } as const satisfies Messages.RequestLogin;
const res = await fetch(new URL('/login', syncServerUri), {
Expand All @@ -178,7 +156,7 @@ export function HypergraphAppProvider({
const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
Identity.storeAccountId(storage, accountId);
Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
const keys = await restoreKeys(signer, accountId, decoded.sessionToken);
const keys = await restoreKeys(signer, accountId, decoded.sessionToken, syncServerUri, storage);
return {
accountId,
sessionToken: decoded.sessionToken,
Expand All @@ -199,7 +177,7 @@ export function HypergraphAppProvider({
}
return await loginWithWallet(signer, accountId, retryCount + 1);
}
const keys = await restoreKeys(signer, accountId, sessionToken);
const keys = await restoreKeys(signer, accountId, sessionToken, syncServerUri, storage);
return {
accountId,
sessionToken,
Expand Down Expand Up @@ -228,8 +206,13 @@ export function HypergraphAppProvider({
}

const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
const sessionNonce = await getSessionNonce(account.address);
const message = prepareSiweMessage(account.address, sessionNonce);
const sessionNonce = await getSessionNonce(account.address, syncServerUri);
const message = prepareSiweMessage(
account.address,
sessionNonce,
{ host: window.location.host, origin: window.location.origin },
chainId,
);
const signature = await account.signMessage({ message });
const req = {
accountId,
Expand Down Expand Up @@ -257,70 +240,6 @@ export function HypergraphAppProvider({
};
}

async function restoreKeys(signer: Identity.Signer, accountId: Address, sessionToken: string) {
const keys = Identity.loadKeys(storage, accountId);
if (keys) {
return keys;
}
// Try to get the users identity from the sync server
const res = await fetch(new URL('/identity/encrypted', syncServerUri), {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
if (res.status === 200) {
console.log('Identity found');
const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json());
const { keyBox } = decoded;
const { ciphertext, nonce } = keyBox;
const keys = await Identity.decryptIdentity(signer, accountId, ciphertext, nonce);
Identity.storeKeys(storage, accountId, keys);
return keys;
}
throw new Error(`Error fetching identity ${res.status}`);
}

async function signup(signer: Identity.Signer, accountId: Address) {
const keys = Identity.createIdentityKeys();
const { ciphertext, nonce } = await Identity.encryptIdentity(signer, accountId, keys);
const { accountProof, keyProof } = await Identity.proveIdentityOwnership(signer, accountId, keys);

const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
const sessionNonce = await getSessionNonce(accountId);
const message = prepareSiweMessage(account.address, sessionNonce);
const signature = await account.signMessage({ message });
const req = {
keyBox: { accountId, ciphertext, nonce },
accountProof,
keyProof,
message,
signaturePublicKey: keys.signaturePublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
signature,
} as const satisfies Messages.RequestCreateIdentity;
const res = await fetch(new URL('/identity', syncServerUri), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(req),
});
if (res.status !== 200) {
// TODO: handle this better?
throw new Error(`Error creating identity: ${res.status}`);
}
const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json());
Identity.storeAccountId(storage, accountId);
Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
Identity.storeKeys(storage, accountId, keys);

return {
accountId,
sessionToken: decoded.sessionToken,
keys,
};
}

async function login(signer: Identity.Signer) {
if (!signer) {
return;
Expand All @@ -336,8 +255,11 @@ export function HypergraphAppProvider({
sessionToken: string;
keys: Identity.IdentityKeys;
};
if (!keys && !(await identityExists(accountId))) {
authData = await signup(signer, accountId);
if (!keys && !(await identityExists(accountId, syncServerUri))) {
authData = await signup(signer, accountId, syncServerUri, chainId, storage, {
host: window.location.host,
origin: window.location.origin,
});
} else if (keys) {
authData = await loginWithKeys(keys, accountId);
} else {
Expand Down
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 './identity-encryption.js';
export * from './login.js';
export * from './prove-ownership.js';
export * from './types.js';
125 changes: 125 additions & 0 deletions packages/hypergraph/src/identity/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as Schema from 'effect/Schema';
import { SiweMessage } from 'siwe';
import type { Address, Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import * as Messages from '../messages/index.js';
import { loadKeys, storeAccountId, storeKeys, storeSyncServerSessionToken } from './auth-storage.js';
import { createIdentityKeys } from './create-identity-keys.js';
import { decryptIdentity, encryptIdentity } from './identity-encryption.js';
import { proveIdentityOwnership } from './prove-ownership.js';
import type { Signer, Storage } from './types.js';

export function prepareSiweMessage(
address: Address,
nonce: string,
location: { host: string; origin: string },
chainId: number,
) {
return new SiweMessage({
domain: location.host,
address,
statement: 'Sign in to Hypergraph',
uri: location.origin,
version: '1',
chainId,
nonce,
expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
}).prepareMessage();
}

export async function identityExists(accountId: string, syncServerUri: string) {
const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), {
method: 'GET',
});
return res.status === 200;
}

export async function getSessionNonce(accountId: string, syncServerUri: string) {
const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce;
const res = await fetch(new URL('/login/nonce', syncServerUri), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(nonceReq),
});
const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json());
return decoded.sessionNonce;
}

export async function restoreKeys(
signer: Signer,
accountId: Address,
sessionToken: string,
syncServerUri: string,
storage: Storage,
) {
const keys = loadKeys(storage, accountId);
if (keys) {
return keys;
}
// Try to get the users identity from the sync server
const res = await fetch(new URL('/identity/encrypted', syncServerUri), {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
if (res.status === 200) {
console.log('Identity found');
const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json());
const { keyBox } = decoded;
const { ciphertext, nonce } = keyBox;
const keys = await decryptIdentity(signer, accountId, ciphertext, nonce);
storeKeys(storage, accountId, keys);
return keys;
}
throw new Error(`Error fetching identity ${res.status}`);
}

export async function signup(
signer: Signer,
accountId: Address,
syncServerUri: string,
chainId: number,
storage: Storage,
location: { host: string; origin: string },
) {
const keys = createIdentityKeys();
const { ciphertext, nonce } = await encryptIdentity(signer, accountId, keys);
const { accountProof, keyProof } = await proveIdentityOwnership(signer, accountId, keys);

const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
const sessionNonce = await getSessionNonce(accountId, syncServerUri);
const message = prepareSiweMessage(account.address, sessionNonce, location, chainId);
const signature = await account.signMessage({ message });
const req = {
keyBox: { accountId, ciphertext, nonce },
accountProof,
keyProof,
message,
signaturePublicKey: keys.signaturePublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
signature,
} as const satisfies Messages.RequestCreateIdentity;
const res = await fetch(new URL('/identity', syncServerUri), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(req),
});
if (res.status !== 200) {
// TODO: handle this better?
throw new Error(`Error creating identity: ${res.status}`);
}
const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json());
storeAccountId(storage, accountId);
storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
storeKeys(storage, accountId, keys);

return {
accountId,
sessionToken: decoded.sessionToken,
keys,
};
}
Loading
Loading