Skip to content

Commit

Permalink
feat(verification): [closes #209] Verified peers (#216)
Browse files Browse the repository at this point in the history
* refactor(bootstrap): add BootstrapShim
* feat(security): [#209] generate public/private keys
* refactor(encryption): move encryption utils to a service
* feat(encryption): [wip] implement convertCryptoKeyToString
* fix(user-settings): serialize crypto keys to strings
* feat(user-settings): deserialize user settings from IndexedDB
* feat(user-settings): upgrade persisted settings on boot
* feat(user-settings): automatically migrate persisted user settings
* refactor(encryption): simplify CryptoKey stringification
* refactor(encryption): DRY up EncryptionService
* feat(verification): send public key to new peers
* refactor(encryption): use class instance
* refactor(serialization): use class instance
* refactor(verification): [wip] create usePeerVerification hook
* feat(verification): encrypt verification token
* feat(verification): send encrypted token to peer
* feat(verification): verify peer
* refactor(verification): use enum for verification state
* feat(verification): expire verification requests
* fix(updatePeer): update with fresh state data
* feat(verification): display verification state
* refactor(usePeerVerification): store verification timer in Peer
* feat(verification): present tooltips explaining verification state
* feat(ui): show full page loading indicator
* feat(init): present bootup failure reasons
* refactor(init): move init to its own file
* feat(verification): show errors upon verification failure
* refactor(verification): move workaround to usePeerVerification
* feat(verification): present peer public keys
* refactor(verification): move peer public key rendering to its own component
* refactor(verification): only pass publicKey into renderer
* feat(verification): show user's own public key
* refactor(naming): rename Username to UserInfo
* refactor(loading): encapsulate height styling
* feat(verification): improve user messaging
* refactor(style): improve formatting and variable names
* feat(verification): add user info tooltip
* docs(verification): explain verification
  • Loading branch information
jeremyckahn committed Dec 9, 2023
1 parent c19bbbe commit 6cbfaac
Show file tree
Hide file tree
Showing 38 changed files with 1,000 additions and 216 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Open https://chitchatter.im/ and join a room to start chatting with anyone else
- Conversation backfilling from peers when a new participant joins.
- Multiline message support (hold `shift` and press `enter`).
- Dark and light themes.
- Automatic peer verification via client-side [public-key cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography).

## Anti-features

Expand Down
32 changes: 16 additions & 16 deletions src/Bootstrap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,29 @@ import { act, render } from '@testing-library/react'
import localforage from 'localforage'

import { PersistedStorageKeys } from 'models/storage'
import {
mockSerializationService,
mockSerializedPrivateKey,
mockSerializedPublicKey,
} from 'test-utils/mocks/mockSerializationService'
import { userSettingsStubFactory } from 'test-utils/stubs/userSettings'

import Bootstrap, { BootstrapProps } from './Bootstrap'
import { Bootstrap, BootstrapProps } from './Bootstrap'

const mockPersistedStorage =
jest.createMockFromModule<jest.Mock<typeof localforage>>('localforage')

const mockGetUuid = jest.fn()

const mockGetItem = jest.fn()
const mockSetItem = jest.fn()

const userSettingsStub = userSettingsStubFactory()

beforeEach(() => {
mockGetItem.mockImplementation(() => Promise.resolve(null))
mockSetItem.mockImplementation((data: any) => Promise.resolve(data))
})

const renderBootstrap = async (overrides: BootstrapProps = {}) => {
const renderBootstrap = async (overrides: Partial<BootstrapProps> = {}) => {
Object.assign(mockPersistedStorage, {
getItem: mockGetItem,
setItem: mockSetItem,
Expand All @@ -27,6 +33,8 @@ const renderBootstrap = async (overrides: BootstrapProps = {}) => {
render(
<Bootstrap
persistedStorage={mockPersistedStorage as any as typeof localforage}
initialUserSettings={userSettingsStub}
serializationService={mockSerializationService}
{...overrides}
/>
)
Expand All @@ -46,9 +54,9 @@ test('checks persistedStorage for user settings', async () => {
expect(mockGetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS)
})

test('persists user settings if none were already persisted', async () => {
test('updates persisted user settings', async () => {
await renderBootstrap({
getUuid: mockGetUuid.mockImplementation(() => 'abc123'),
initialUserSettings: { ...userSettingsStub, userId: 'abc123' },
})

expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, {
Expand All @@ -58,15 +66,7 @@ test('persists user settings if none were already persisted', async () => {
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey: mockSerializedPublicKey,
privateKey: mockSerializedPrivateKey,
})
})

test('does not update user settings if they were already persisted', async () => {
mockGetItem.mockImplementation(() => ({
userId: 'abc123',
}))

await renderBootstrap()

expect(mockSetItem).not.toHaveBeenCalled()
})
64 changes: 37 additions & 27 deletions src/Bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
Route,
Navigate,
} from 'react-router-dom'
import { v4 as uuid } from 'uuid'
import localforage from 'localforage'

import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
Expand All @@ -18,19 +17,25 @@ import { Disclaimer } from 'pages/Disclaimer'
import { Settings } from 'pages/Settings'
import { PublicRoom } from 'pages/PublicRoom'
import { PrivateRoom } from 'pages/PrivateRoom'
import { ColorMode, UserSettings } from 'models/settings'
import { UserSettings } from 'models/settings'
import { PersistedStorageKeys } from 'models/storage'
import { QueryParamKeys } from 'models/shell'
import { Shell } from 'components/Shell'
import { WholePageLoading } from 'components/Loading/Loading'
import {
isConfigMessageEvent,
PostMessageEvent,
PostMessageEventName,
} from 'models/sdk'
import {
serializationService as serializationServiceInstance,
SerializedUserSettings,
} from 'services/Serialization'

export interface BootstrapProps {
persistedStorage?: typeof localforage
getUuid?: typeof uuid
initialUserSettings: UserSettings
serializationService?: typeof serializationServiceInstance
}

const configListenerTimeout = 3000
Expand Down Expand Up @@ -71,13 +76,14 @@ const getConfigFromSdk = () => {
})
}

function Bootstrap({
export const Bootstrap = ({
persistedStorage: persistedStorageProp = localforage.createInstance({
name: 'chitchatter',
description: 'Persisted settings data for chitchatter',
}),
getUuid = uuid,
}: BootstrapProps) {
initialUserSettings,
serializationService = serializationServiceInstance,
}: BootstrapProps) => {
const queryParams = useMemo(
() => new URLSearchParams(window.location.search),
[]
Expand All @@ -86,32 +92,29 @@ function Bootstrap({
const [persistedStorage] = useState(persistedStorageProp)
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
const [userSettings, setUserSettings] = useState<UserSettings>({
userId: getUuid(),
customUsername: '',
colorMode: ColorMode.DARK,
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
})
const [userSettings, setUserSettings] =
useState<UserSettings>(initialUserSettings)
const { userId } = userSettings

const handleServiceWorkerUpdate = () => {
setAppNeedsUpdate(true)
}

const persistUserSettings = useCallback(
(newUserSettings: UserSettings) => {
async (newUserSettings: UserSettings) => {
if (queryParams.has(QueryParamKeys.IS_EMBEDDED)) {
return Promise.resolve(userSettings)
}

const userSettingsForIndexedDb =
await serializationService.serializeUserSettings(newUserSettings)

return persistedStorageProp.setItem(
PersistedStorageKeys.USER_SETTINGS,
newUserSettings
userSettingsForIndexedDb
)
},
[persistedStorageProp, queryParams, userSettings]
[persistedStorageProp, queryParams, serializationService, userSettings]
)

useEffect(() => {
Expand All @@ -122,9 +125,19 @@ function Bootstrap({
;(async () => {
if (hasLoadedSettings) return

const persistedUserSettings =
await persistedStorageProp.getItem<UserSettings>(
const serializedUserSettings = {
// NOTE: This migrates persisted user settings data to latest version
...(await serializationService.serializeUserSettings(
initialUserSettings
)),
...(await persistedStorageProp.getItem<SerializedUserSettings>(
PersistedStorageKeys.USER_SETTINGS
)),
}

const persistedUserSettings =
await serializationService.deserializeUserSettings(
serializedUserSettings
)

const computeUserSettings = async (): Promise<UserSettings> => {
Expand Down Expand Up @@ -152,12 +165,9 @@ function Bootstrap({

const computedUserSettings = await computeUserSettings()
setUserSettings(computedUserSettings)

if (persistedUserSettings === null) {
await persistUserSettings(computedUserSettings)
}

setHasLoadedSettings(true)

await persistUserSettings(computedUserSettings)
})()
}, [
hasLoadedSettings,
Expand All @@ -166,6 +176,8 @@ function Bootstrap({
userId,
queryParams,
persistUserSettings,
serializationService,
initialUserSettings,
])

useEffect(() => {
Expand Down Expand Up @@ -245,12 +257,10 @@ function Bootstrap({
</Routes>
</Shell>
) : (
<></>
<WholePageLoading />
)}
</SettingsContext.Provider>
</StorageContext.Provider>
</Router>
)
}

export default Bootstrap
80 changes: 80 additions & 0 deletions src/Init.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import { v4 as uuid } from 'uuid'

import { encryptionService } from 'services/Encryption'
import {
EnvironmentUnsupportedDialog,
isEnvironmentSupported,
} from 'components/Shell/EnvironmentUnsupportedDialog'
import { WholePageLoading } from 'components/Loading/Loading'
import { ColorMode, UserSettings } from 'models/settings'

import { Bootstrap, BootstrapProps } from './Bootstrap'

export interface InitProps extends Omit<BootstrapProps, 'initialUserSettings'> {
getUuid?: typeof uuid
}

// NOTE: This is meant to be a thin layer around the Bootstrap component that
// only handles asynchronous creation of the public/private keys that Bootstrap
// requires.
const Init = ({ getUuid = uuid, ...props }: InitProps) => {
const [userSettings, setUserSettings] = useState<UserSettings | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)

useEffect(() => {
;(async () => {
if (userSettings !== null) return

try {
const { publicKey, privateKey } =
await encryptionService.generateKeyPair()

setUserSettings({
userId: getUuid(),
customUsername: '',
colorMode: ColorMode.DARK,
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey,
privateKey,
})
} catch (e) {
console.error(e)
setErrorMessage(
'Chitchatter was unable to boot up. Please check the browser console.'
)
}
})()
}, [getUuid, userSettings])

if (!isEnvironmentSupported) {
return <EnvironmentUnsupportedDialog />
}

if (errorMessage) {
return (
<Box
sx={{
display: 'flex',
height: '100vh',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Typography>{errorMessage}</Typography>
</Box>
)
}

if (userSettings === null) {
return <WholePageLoading />
}

return <Bootstrap {...props} initialUserSettings={userSettings} />
}

export default Init
26 changes: 26 additions & 0 deletions src/components/Loading/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Box, { BoxProps } from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'

interface WholePageLoadingProps extends BoxProps {}

export const WholePageLoading = ({
sx = [],
...props
}: WholePageLoadingProps) => {
return (
<Box
sx={[
{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
>
<CircularProgress />
</Box>
)
}
1 change: 1 addition & 0 deletions src/components/Loading/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Loading'
38 changes: 38 additions & 0 deletions src/components/PublicKey/PublicKey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'
import { CopyableBlock } from 'components/CopyableBlock/CopyableBlock'
import { encryptionService } from 'services/Encryption/Encryption'

interface PeerPublicKeyProps {
publicKey: CryptoKey
}

export const PublicKey = ({ publicKey }: PeerPublicKeyProps) => {
const [publicKeyString, setPublicKeyString] = useState('')

useEffect(() => {
;(async () => {
setPublicKeyString(await encryptionService.stringifyCryptoKey(publicKey))
})()
}, [publicKey])

return (
<CopyableBlock>
<SyntaxHighlighter
language="plaintext"
style={materialDark}
PreTag="div"
lineProps={{
style: {
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
},
}}
wrapLines={true}
>
{publicKeyString}
</SyntaxHighlighter>
</CopyableBlock>
)
}
1 change: 1 addition & 0 deletions src/components/PublicKey/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './PublicKey'
Loading

1 comment on commit 6cbfaac

@vercel
Copy link

@vercel vercel bot commented on 6cbfaac Dec 10, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

chitchatter – ./

chitchatter-jeremyckahn.vercel.app
chitchatter-git-main-jeremyckahn.vercel.app
chitchatter.vercel.app

Please sign in to comment.