diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 7e5fb011..ec437b55 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -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'; @@ -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'; @@ -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), { @@ -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, @@ -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, @@ -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, @@ -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; @@ -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 { diff --git a/packages/hypergraph/src/identity/index.ts b/packages/hypergraph/src/identity/index.ts index 8defea0f..13d83182 100644 --- a/packages/hypergraph/src/identity/index.ts +++ b/packages/hypergraph/src/identity/index.ts @@ -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'; diff --git a/packages/hypergraph/src/identity/login.ts b/packages/hypergraph/src/identity/login.ts new file mode 100644 index 00000000..d231a44d --- /dev/null +++ b/packages/hypergraph/src/identity/login.ts @@ -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, + }; +} diff --git a/packages/hypergraph/test/identity/login.test.ts b/packages/hypergraph/test/identity/login.test.ts new file mode 100644 index 00000000..fd49ce41 --- /dev/null +++ b/packages/hypergraph/test/identity/login.test.ts @@ -0,0 +1,305 @@ +import { privateKeyToAccount } from 'viem/accounts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { encryptIdentity } from '../../src/identity/identity-encryption'; +import { getSessionNonce, prepareSiweMessage, restoreKeys, signup } from '../../src/identity/login'; +import type { IdentityKeys, Signer, Storage } from '../../src/identity/types'; +import type * as Messages from '../../src/messages'; + +describe('prepareSiweMessage', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Set fixed date for consistent tests + vi.setSystemTime(new Date('2024-01-01')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create a valid SIWE message with correct parameters', () => { + const address = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + const nonce = 'Sv2dJppgx9SKDGCIb'; // generateNonce from siwe + const chainId = 1; + + const message = prepareSiweMessage( + address, + nonce, + { host: 'test.example.com', origin: 'https://test.example.com' }, + chainId, + ); + + expect(message).toContain(`${address}`); + expect(message).toContain('test.example.com'); + expect(message).toContain('Sign in to Hypergraph'); + expect(message).toContain('https://test.example.com'); + expect(message).toContain(nonce); + expect(message).toContain('Version: 1'); + expect(message).toContain('Chain ID: 1'); + + const expectedExpiration = new Date('2024-01-31').toISOString(); + expect(message).toContain(expectedExpiration); + }); + + it('should create different messages for different addresses', () => { + const address1 = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + const address2 = '0x098B742F2696AFC37724887cf999e1cFdB8f4b55'; + const nonce = 'Sv2dJppgx9SKDGCIb'; // generateNonce from siwe + const chainId = 1; + + const message1 = prepareSiweMessage( + address1, + nonce, + { host: 'test.example.com', origin: 'https://test.example.com' }, + chainId, + ); + const message2 = prepareSiweMessage( + address2, + nonce, + { host: 'test.example.com', origin: 'https://test.example.com' }, + chainId, + ); + + expect(message1).not.toBe(message2); + }); + + it('should create different messages for different chain IDs', () => { + const address = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + const nonce = 'Sv2dJppgx9SKDGCIb'; // generateNonce from siwe + + const message1 = prepareSiweMessage( + address, + nonce, + { host: 'test.example.com', origin: 'https://test.example.com' }, + 1, + ); + const message2 = prepareSiweMessage( + address, + nonce, + { host: 'test.example.com', origin: 'https://test.example.com' }, + 137, + ); + + expect(message1).not.toBe(message2); + }); +}); + +describe('getSessionNonce', () => { + // mock fetch to http://localhost:3000/login/nonce to return a nonce + beforeEach(() => { + vi.spyOn(global, 'fetch').mockImplementation((url) => { + if (url.toString() === 'http://localhost:3000/login/nonce') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }), + } as Response); + } + return vi.fn() as never; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return a nonce', () => { + const nonce = getSessionNonce('0x742d35Cc6634C0532925a3b844Bc454e4438f44e', 'http://localhost:3000'); + expect(nonce).toBeDefined(); + }); +}); + +describe('restoreKeys', () => { + const accountPrivateKey = '0xda75e4ea10de7b3ba2d4212cc16bfbb6cac6aed04ac59a28c3231994d8027a9f'; + const account = privateKeyToAccount(accountPrivateKey); + + const signer: Signer = { + signMessage: (message) => { + return account.signMessage({ message }); + }, + getAddress: () => account.address, + }; + + const mockStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + // mock fetch to http://localhost:3000/login/nonce to return a nonce + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(global, 'fetch').mockImplementation((url) => { + if (url.toString() === 'http://localhost:3000/identity/encrypted') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }), + } as Response); + } + return vi.fn() as never; + }); + }); + + it('should return keys from storage if they exist', async () => { + const accountId = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + const mockKeys: IdentityKeys = { + encryptionPrivateKey: 'encryptionPrivateKey', + encryptionPublicKey: 'encryptionPublicKey', + signaturePrivateKey: 'signaturePrivateKey', + signaturePublicKey: 'signaturePublicKey', + }; + mockStorage.getItem.mockReturnValue(JSON.stringify(mockKeys)); + + const result = await restoreKeys(signer, accountId, 'session-token', 'http://localhost:3000', mockStorage); + + expect(result).toEqual(mockKeys); + expect(mockStorage.getItem).toHaveBeenCalledWith('hypergraph:dev:keys:0x742d35Cc6634C0532925a3b844Bc454e4438f44e'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should fetch and decrypt keys if not in storage', async () => { + const accountId = account.address; + const identityKeys: IdentityKeys = { + encryptionPrivateKey: 'encryptionPrivateKey', + encryptionPublicKey: 'encryptionPublicKey', + signaturePrivateKey: 'signaturePrivateKey', + signaturePublicKey: 'signaturePublicKey', + }; + + const encryptedIdentity = await encryptIdentity(signer, accountId, identityKeys); + + const mockEncryptedKeys: Messages.ResponseIdentityEncrypted = { + keyBox: { + accountId, + ciphertext: encryptedIdentity.ciphertext, + nonce: encryptedIdentity.nonce, + }, + }; + + mockStorage.getItem.mockReturnValue(null); + + vi.spyOn(global, 'fetch').mockImplementation((url) => { + if (url.toString() === 'http://localhost:3000/identity/encrypted') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve(mockEncryptedKeys), + } as Response); + } + return vi.fn() as never; + }); + + const result = await restoreKeys(signer, accountId, 'session-token', 'http://localhost:3000', mockStorage); + + expect(result).toEqual(identityKeys); + expect(mockStorage.getItem).toHaveBeenCalledWith('hypergraph:dev:keys:0x4aAE31951Dfd101d95c2b90e6a8a44b49867346E'); + expect(JSON.parse(mockStorage.setItem.mock.calls[0][1])).toEqual(identityKeys); + expect(fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + headers: { + Authorization: 'Bearer session-token', + }, + }), + ); + }); + + it('should throw error if fetch fails', async () => { + const accountId = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + + mockStorage.getItem.mockReturnValue(null); + + vi.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + status: 404, + } as Response), + ); + + await expect(restoreKeys(signer, accountId, 'session-token', 'http://localhost:3000', mockStorage)).rejects.toThrow( + 'Error fetching identity 404', + ); + }); +}); + +describe('signup', () => { + let mockStorage: Storage; + + const accountPrivateKey = '0xda75e4ea10de7b3ba2d4212cc16bfbb6cac6aed04ac59a28c3231994d8027a9f'; + const account = privateKeyToAccount(accountPrivateKey); + const accountId = account.address; + + const signer: Signer = { + signMessage: (message) => { + return account.signMessage({ message }); + }, + getAddress: () => account.address, + }; + + const location = { host: 'localhost', origin: 'http://localhost' }; + + beforeEach(() => { + mockStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + }); + + it('should create and store new identity', async () => { + const sessionToken = 'session-token'; + + vi.spyOn(global, 'fetch').mockImplementation((url) => { + if (url.toString() === 'http://localhost:3000/login/nonce') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }), + } as Response); + } + if (url.toString() === 'http://localhost:3000/identity') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ sessionToken }), + } as Response); + } + return vi.fn() as never; + }); + + const result = await signup(signer, accountId, 'http://localhost:3000', 1, mockStorage, location); + + expect(result.accountId).toEqual(accountId); + expect(result.sessionToken).toEqual(sessionToken); + expect(result.keys.encryptionPublicKey).toBeDefined(); + expect(result.keys.encryptionPrivateKey).toBeDefined(); + expect(result.keys.signaturePublicKey).toBeDefined(); + expect(result.keys.signaturePrivateKey).toBeDefined(); + + expect(mockStorage.setItem).toHaveBeenCalledTimes(3); + expect(fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + }); + + it('should throw error if identity creation fails', async () => { + vi.spyOn(global, 'fetch').mockImplementation((url) => { + if (url.toString() === 'http://localhost:3000/login/nonce') { + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }), + } as Response); + } + if (url.toString() === 'http://localhost:3000/identity') { + return Promise.resolve({ + status: 500, + } as Response); + } + return vi.fn() as never; + }); + + await expect(signup(signer, accountId, 'http://localhost:3000', 1, mockStorage, location)).rejects.toThrow( + 'Error creating identity: 500', + ); + }); +});