* 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
This commit is contained in:
parent
c19bbbeee2
commit
6cbfaacf1a
@ -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.
|
- Conversation backfilling from peers when a new participant joins.
|
||||||
- Multiline message support (hold `shift` and press `enter`).
|
- Multiline message support (hold `shift` and press `enter`).
|
||||||
- Dark and light themes.
|
- Dark and light themes.
|
||||||
|
- Automatic peer verification via client-side [public-key cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography).
|
||||||
|
|
||||||
## Anti-features
|
## Anti-features
|
||||||
|
|
||||||
|
@ -2,23 +2,29 @@ import { act, render } from '@testing-library/react'
|
|||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
|
||||||
import { PersistedStorageKeys } from 'models/storage'
|
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 =
|
const mockPersistedStorage =
|
||||||
jest.createMockFromModule<jest.Mock<typeof localforage>>('localforage')
|
jest.createMockFromModule<jest.Mock<typeof localforage>>('localforage')
|
||||||
|
|
||||||
const mockGetUuid = jest.fn()
|
|
||||||
|
|
||||||
const mockGetItem = jest.fn()
|
const mockGetItem = jest.fn()
|
||||||
const mockSetItem = jest.fn()
|
const mockSetItem = jest.fn()
|
||||||
|
|
||||||
|
const userSettingsStub = userSettingsStubFactory()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetItem.mockImplementation(() => Promise.resolve(null))
|
mockGetItem.mockImplementation(() => Promise.resolve(null))
|
||||||
mockSetItem.mockImplementation((data: any) => Promise.resolve(data))
|
mockSetItem.mockImplementation((data: any) => Promise.resolve(data))
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderBootstrap = async (overrides: BootstrapProps = {}) => {
|
const renderBootstrap = async (overrides: Partial<BootstrapProps> = {}) => {
|
||||||
Object.assign(mockPersistedStorage, {
|
Object.assign(mockPersistedStorage, {
|
||||||
getItem: mockGetItem,
|
getItem: mockGetItem,
|
||||||
setItem: mockSetItem,
|
setItem: mockSetItem,
|
||||||
@ -27,6 +33,8 @@ const renderBootstrap = async (overrides: BootstrapProps = {}) => {
|
|||||||
render(
|
render(
|
||||||
<Bootstrap
|
<Bootstrap
|
||||||
persistedStorage={mockPersistedStorage as any as typeof localforage}
|
persistedStorage={mockPersistedStorage as any as typeof localforage}
|
||||||
|
initialUserSettings={userSettingsStub}
|
||||||
|
serializationService={mockSerializationService}
|
||||||
{...overrides}
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -46,9 +54,9 @@ test('checks persistedStorage for user settings', async () => {
|
|||||||
expect(mockGetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS)
|
expect(mockGetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('persists user settings if none were already persisted', async () => {
|
test('updates persisted user settings', async () => {
|
||||||
await renderBootstrap({
|
await renderBootstrap({
|
||||||
getUuid: mockGetUuid.mockImplementation(() => 'abc123'),
|
initialUserSettings: { ...userSettingsStub, userId: 'abc123' },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, {
|
expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, {
|
||||||
@ -58,15 +66,7 @@ test('persists user settings if none were already persisted', async () => {
|
|||||||
playSoundOnNewMessage: true,
|
playSoundOnNewMessage: true,
|
||||||
showNotificationOnNewMessage: true,
|
showNotificationOnNewMessage: true,
|
||||||
showActiveTypingStatus: 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()
|
|
||||||
})
|
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
Navigate,
|
Navigate,
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
import { v4 as uuid } from 'uuid'
|
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
|
||||||
import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
|
import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
|
||||||
@ -18,19 +17,25 @@ import { Disclaimer } from 'pages/Disclaimer'
|
|||||||
import { Settings } from 'pages/Settings'
|
import { Settings } from 'pages/Settings'
|
||||||
import { PublicRoom } from 'pages/PublicRoom'
|
import { PublicRoom } from 'pages/PublicRoom'
|
||||||
import { PrivateRoom } from 'pages/PrivateRoom'
|
import { PrivateRoom } from 'pages/PrivateRoom'
|
||||||
import { ColorMode, UserSettings } from 'models/settings'
|
import { UserSettings } from 'models/settings'
|
||||||
import { PersistedStorageKeys } from 'models/storage'
|
import { PersistedStorageKeys } from 'models/storage'
|
||||||
import { QueryParamKeys } from 'models/shell'
|
import { QueryParamKeys } from 'models/shell'
|
||||||
import { Shell } from 'components/Shell'
|
import { Shell } from 'components/Shell'
|
||||||
|
import { WholePageLoading } from 'components/Loading/Loading'
|
||||||
import {
|
import {
|
||||||
isConfigMessageEvent,
|
isConfigMessageEvent,
|
||||||
PostMessageEvent,
|
PostMessageEvent,
|
||||||
PostMessageEventName,
|
PostMessageEventName,
|
||||||
} from 'models/sdk'
|
} from 'models/sdk'
|
||||||
|
import {
|
||||||
|
serializationService as serializationServiceInstance,
|
||||||
|
SerializedUserSettings,
|
||||||
|
} from 'services/Serialization'
|
||||||
|
|
||||||
export interface BootstrapProps {
|
export interface BootstrapProps {
|
||||||
persistedStorage?: typeof localforage
|
persistedStorage?: typeof localforage
|
||||||
getUuid?: typeof uuid
|
initialUserSettings: UserSettings
|
||||||
|
serializationService?: typeof serializationServiceInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
const configListenerTimeout = 3000
|
const configListenerTimeout = 3000
|
||||||
@ -71,13 +76,14 @@ const getConfigFromSdk = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bootstrap({
|
export const Bootstrap = ({
|
||||||
persistedStorage: persistedStorageProp = localforage.createInstance({
|
persistedStorage: persistedStorageProp = localforage.createInstance({
|
||||||
name: 'chitchatter',
|
name: 'chitchatter',
|
||||||
description: 'Persisted settings data for chitchatter',
|
description: 'Persisted settings data for chitchatter',
|
||||||
}),
|
}),
|
||||||
getUuid = uuid,
|
initialUserSettings,
|
||||||
}: BootstrapProps) {
|
serializationService = serializationServiceInstance,
|
||||||
|
}: BootstrapProps) => {
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => new URLSearchParams(window.location.search),
|
() => new URLSearchParams(window.location.search),
|
||||||
[]
|
[]
|
||||||
@ -86,14 +92,8 @@ function Bootstrap({
|
|||||||
const [persistedStorage] = useState(persistedStorageProp)
|
const [persistedStorage] = useState(persistedStorageProp)
|
||||||
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
|
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
|
||||||
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
|
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
|
||||||
const [userSettings, setUserSettings] = useState<UserSettings>({
|
const [userSettings, setUserSettings] =
|
||||||
userId: getUuid(),
|
useState<UserSettings>(initialUserSettings)
|
||||||
customUsername: '',
|
|
||||||
colorMode: ColorMode.DARK,
|
|
||||||
playSoundOnNewMessage: true,
|
|
||||||
showNotificationOnNewMessage: true,
|
|
||||||
showActiveTypingStatus: true,
|
|
||||||
})
|
|
||||||
const { userId } = userSettings
|
const { userId } = userSettings
|
||||||
|
|
||||||
const handleServiceWorkerUpdate = () => {
|
const handleServiceWorkerUpdate = () => {
|
||||||
@ -101,17 +101,20 @@ function Bootstrap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const persistUserSettings = useCallback(
|
const persistUserSettings = useCallback(
|
||||||
(newUserSettings: UserSettings) => {
|
async (newUserSettings: UserSettings) => {
|
||||||
if (queryParams.has(QueryParamKeys.IS_EMBEDDED)) {
|
if (queryParams.has(QueryParamKeys.IS_EMBEDDED)) {
|
||||||
return Promise.resolve(userSettings)
|
return Promise.resolve(userSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userSettingsForIndexedDb =
|
||||||
|
await serializationService.serializeUserSettings(newUserSettings)
|
||||||
|
|
||||||
return persistedStorageProp.setItem(
|
return persistedStorageProp.setItem(
|
||||||
PersistedStorageKeys.USER_SETTINGS,
|
PersistedStorageKeys.USER_SETTINGS,
|
||||||
newUserSettings
|
userSettingsForIndexedDb
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[persistedStorageProp, queryParams, userSettings]
|
[persistedStorageProp, queryParams, serializationService, userSettings]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -122,9 +125,19 @@ function Bootstrap({
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
if (hasLoadedSettings) return
|
if (hasLoadedSettings) return
|
||||||
|
|
||||||
const persistedUserSettings =
|
const serializedUserSettings = {
|
||||||
await persistedStorageProp.getItem<UserSettings>(
|
// NOTE: This migrates persisted user settings data to latest version
|
||||||
|
...(await serializationService.serializeUserSettings(
|
||||||
|
initialUserSettings
|
||||||
|
)),
|
||||||
|
...(await persistedStorageProp.getItem<SerializedUserSettings>(
|
||||||
PersistedStorageKeys.USER_SETTINGS
|
PersistedStorageKeys.USER_SETTINGS
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedUserSettings =
|
||||||
|
await serializationService.deserializeUserSettings(
|
||||||
|
serializedUserSettings
|
||||||
)
|
)
|
||||||
|
|
||||||
const computeUserSettings = async (): Promise<UserSettings> => {
|
const computeUserSettings = async (): Promise<UserSettings> => {
|
||||||
@ -152,12 +165,9 @@ function Bootstrap({
|
|||||||
|
|
||||||
const computedUserSettings = await computeUserSettings()
|
const computedUserSettings = await computeUserSettings()
|
||||||
setUserSettings(computedUserSettings)
|
setUserSettings(computedUserSettings)
|
||||||
|
|
||||||
if (persistedUserSettings === null) {
|
|
||||||
await persistUserSettings(computedUserSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasLoadedSettings(true)
|
setHasLoadedSettings(true)
|
||||||
|
|
||||||
|
await persistUserSettings(computedUserSettings)
|
||||||
})()
|
})()
|
||||||
}, [
|
}, [
|
||||||
hasLoadedSettings,
|
hasLoadedSettings,
|
||||||
@ -166,6 +176,8 @@ function Bootstrap({
|
|||||||
userId,
|
userId,
|
||||||
queryParams,
|
queryParams,
|
||||||
persistUserSettings,
|
persistUserSettings,
|
||||||
|
serializationService,
|
||||||
|
initialUserSettings,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -245,12 +257,10 @@ function Bootstrap({
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Shell>
|
</Shell>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<WholePageLoading />
|
||||||
)}
|
)}
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
</StorageContext.Provider>
|
</StorageContext.Provider>
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Bootstrap
|
|
||||||
|
80
src/Init.tsx
Normal file
80
src/Init.tsx
Normal file
@ -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
src/components/Loading/Loading.tsx
Normal file
26
src/components/Loading/Loading.tsx
Normal file
@ -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
src/components/Loading/index.ts
Normal file
1
src/components/Loading/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Loading'
|
38
src/components/PublicKey/PublicKey.tsx
Normal file
38
src/components/PublicKey/PublicKey.tsx
Normal file
@ -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
src/components/PublicKey/index.ts
Normal file
1
src/components/PublicKey/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './PublicKey'
|
@ -4,10 +4,11 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
|
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
|
||||||
|
import { mockEncryptionService } from 'test-utils/mocks/mockEncryptionService'
|
||||||
|
|
||||||
import { SettingsContext } from 'contexts/SettingsContext'
|
import { SettingsContext } from 'contexts/SettingsContext'
|
||||||
|
|
||||||
import { Room } from './'
|
import { Room, RoomProps } from './'
|
||||||
|
|
||||||
const mockUserId = 'user-id'
|
const mockUserId = 'user-id'
|
||||||
const mockRoomId = 'room-123'
|
const mockRoomId = 'room-123'
|
||||||
@ -54,11 +55,15 @@ const RouteStub = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
jest.useFakeTimers().setSystemTime(100)
|
jest.useFakeTimers().setSystemTime(100)
|
||||||
|
|
||||||
|
const RoomStub = (props: RoomProps) => {
|
||||||
|
return <Room encryptionService={mockEncryptionService} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
describe('Room', () => {
|
describe('Room', () => {
|
||||||
test('is available', () => {
|
test('is available', () => {
|
||||||
render(
|
render(
|
||||||
<RouteStub>
|
<RouteStub>
|
||||||
<Room userId={mockUserId} roomId={mockRoomId} />
|
<RoomStub userId={mockUserId} roomId={mockRoomId} />
|
||||||
</RouteStub>
|
</RouteStub>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -66,7 +71,7 @@ describe('Room', () => {
|
|||||||
test('send button is disabled', () => {
|
test('send button is disabled', () => {
|
||||||
render(
|
render(
|
||||||
<RouteStub>
|
<RouteStub>
|
||||||
<Room userId={mockUserId} roomId={mockRoomId} />
|
<RoomStub userId={mockUserId} roomId={mockRoomId} />
|
||||||
</RouteStub>
|
</RouteStub>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -77,7 +82,7 @@ describe('Room', () => {
|
|||||||
test('inputting text enabled send button', async () => {
|
test('inputting text enabled send button', async () => {
|
||||||
render(
|
render(
|
||||||
<RouteStub>
|
<RouteStub>
|
||||||
<Room userId={mockUserId} roomId={mockRoomId} />
|
<RoomStub userId={mockUserId} roomId={mockRoomId} />
|
||||||
</RouteStub>
|
</RouteStub>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -94,7 +99,7 @@ describe('Room', () => {
|
|||||||
test('sending a message clears the text input', async () => {
|
test('sending a message clears the text input', async () => {
|
||||||
render(
|
render(
|
||||||
<RouteStub>
|
<RouteStub>
|
||||||
<Room userId={mockUserId} roomId={mockRoomId} />
|
<RoomStub userId={mockUserId} roomId={mockRoomId} />
|
||||||
</RouteStub>
|
</RouteStub>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -115,7 +120,7 @@ describe('Room', () => {
|
|||||||
test('message is sent to peer', async () => {
|
test('message is sent to peer', async () => {
|
||||||
render(
|
render(
|
||||||
<RouteStub>
|
<RouteStub>
|
||||||
<Room
|
<RoomStub
|
||||||
getUuid={mockGetUuid.mockImplementation(() => 'abc123')}
|
getUuid={mockGetUuid.mockImplementation(() => 'abc123')}
|
||||||
userId={mockUserId}
|
userId={mockUserId}
|
||||||
roomId={mockRoomId}
|
roomId={mockRoomId}
|
||||||
|
@ -12,7 +12,7 @@ import { RoomContext } from 'contexts/RoomContext'
|
|||||||
import { ShellContext } from 'contexts/ShellContext'
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
import { MessageForm } from 'components/MessageForm'
|
import { MessageForm } from 'components/MessageForm'
|
||||||
import { ChatTranscript } from 'components/ChatTranscript'
|
import { ChatTranscript } from 'components/ChatTranscript'
|
||||||
|
import { encryptionService as encryptionServiceInstance } from 'services/Encryption'
|
||||||
import { SettingsContext } from 'contexts/SettingsContext'
|
import { SettingsContext } from 'contexts/SettingsContext'
|
||||||
|
|
||||||
import { useRoom } from './useRoom'
|
import { useRoom } from './useRoom'
|
||||||
@ -30,18 +30,21 @@ export interface RoomProps {
|
|||||||
password?: string
|
password?: string
|
||||||
roomId: string
|
roomId: string
|
||||||
userId: string
|
userId: string
|
||||||
|
encryptionService?: typeof encryptionServiceInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Room({
|
export function Room({
|
||||||
appId = `${encodeURI(window.location.origin)}_${process.env.REACT_APP_NAME}`,
|
appId = `${encodeURI(window.location.origin)}_${process.env.REACT_APP_NAME}`,
|
||||||
getUuid = uuid,
|
getUuid = uuid,
|
||||||
|
encryptionService = encryptionServiceInstance,
|
||||||
roomId,
|
roomId,
|
||||||
password,
|
password,
|
||||||
userId,
|
userId,
|
||||||
}: RoomProps) {
|
}: RoomProps) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const settingsContext = useContext(SettingsContext)
|
const settingsContext = useContext(SettingsContext)
|
||||||
const { showActiveTypingStatus } = settingsContext.getUserSettings()
|
const { showActiveTypingStatus, publicKey } =
|
||||||
|
settingsContext.getUserSettings()
|
||||||
const {
|
const {
|
||||||
isMessageSending,
|
isMessageSending,
|
||||||
handleInlineMediaUpload,
|
handleInlineMediaUpload,
|
||||||
@ -63,6 +66,8 @@ export function Room({
|
|||||||
roomId,
|
roomId,
|
||||||
userId,
|
userId,
|
||||||
getUuid,
|
getUuid,
|
||||||
|
publicKey,
|
||||||
|
encryptionService,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
144
src/components/Room/usePeerVerification.ts
Normal file
144
src/components/Room/usePeerVerification.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
import { Peer, PeerVerificationState } from 'models/chat'
|
||||||
|
import { encryptionService as encryptionServiceInstance } from 'services/Encryption'
|
||||||
|
import { PeerRoom } from 'services/PeerRoom'
|
||||||
|
import { PeerActions } from 'models/network'
|
||||||
|
import { verificationTimeout } from 'config/messaging'
|
||||||
|
import { usePeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
|
|
||||||
|
interface UserPeerVerificationProps {
|
||||||
|
peerRoom: PeerRoom
|
||||||
|
privateKey: CryptoKey
|
||||||
|
encryptionService?: typeof encryptionServiceInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePeerVerification = ({
|
||||||
|
peerRoom,
|
||||||
|
privateKey,
|
||||||
|
encryptionService = encryptionServiceInstance,
|
||||||
|
}: UserPeerVerificationProps) => {
|
||||||
|
const { updatePeer, peerList, showAlert } = useContext(ShellContext)
|
||||||
|
|
||||||
|
const { getDisplayUsername } = usePeerNameDisplay()
|
||||||
|
|
||||||
|
const [sendVerificationTokenEncrypted, receiveVerificationTokenEncrypted] =
|
||||||
|
peerRoom.makeAction<ArrayBuffer>(PeerActions.VERIFICATION_TOKEN_ENCRYPTED)
|
||||||
|
|
||||||
|
const [sendVerificationTokenRaw, receiveVerificationTokenRaw] =
|
||||||
|
peerRoom.makeAction<string>(PeerActions.VERIFICATION_TOKEN_RAW)
|
||||||
|
|
||||||
|
const initPeerVerification = useCallback(
|
||||||
|
async (peer: Peer) => {
|
||||||
|
const { verificationToken } = peer
|
||||||
|
|
||||||
|
const encryptedVerificationToken = await encryptionService.encryptString(
|
||||||
|
peer.publicKey,
|
||||||
|
verificationToken
|
||||||
|
)
|
||||||
|
|
||||||
|
const verificationTimer = setTimeout(() => {
|
||||||
|
updatePeer(peer.peerId, {
|
||||||
|
verificationState: PeerVerificationState.UNVERIFIED,
|
||||||
|
verificationTimer: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
showAlert(
|
||||||
|
`Verification for ${getDisplayUsername(peer.userId)} timed out`,
|
||||||
|
{
|
||||||
|
severity: 'error',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
console.warn(`Verification for peerId ${peer.peerId} timed out`)
|
||||||
|
}, verificationTimeout)
|
||||||
|
|
||||||
|
updatePeer(peer.peerId, { encryptedVerificationToken, verificationTimer })
|
||||||
|
|
||||||
|
await sendVerificationTokenEncrypted(encryptedVerificationToken, [
|
||||||
|
peer.peerId,
|
||||||
|
])
|
||||||
|
},
|
||||||
|
[
|
||||||
|
encryptionService,
|
||||||
|
getDisplayUsername,
|
||||||
|
sendVerificationTokenEncrypted,
|
||||||
|
showAlert,
|
||||||
|
updatePeer,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: This useState and useEffect is a hacky workaround for stale data
|
||||||
|
// being used when verifying new peers. It would be much simpler to call
|
||||||
|
// initPeerVerification directly, but doing so when the peer metadata is
|
||||||
|
// received results in peerList being out of date (which is used by
|
||||||
|
// getDisplayUsername).
|
||||||
|
const [scheduledPeerToVerify, setScheduledPeerToVerify] =
|
||||||
|
useState<Peer | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (scheduledPeerToVerify === null) return
|
||||||
|
|
||||||
|
initPeerVerification(scheduledPeerToVerify)
|
||||||
|
setScheduledPeerToVerify(null)
|
||||||
|
}, [scheduledPeerToVerify, initPeerVerification])
|
||||||
|
// NOTE: END HACKY WORKAROUND
|
||||||
|
|
||||||
|
const verifyPeer = (peer: Peer) => {
|
||||||
|
setScheduledPeerToVerify(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveVerificationTokenEncrypted(
|
||||||
|
async (encryptedVerificationToken, peerId) => {
|
||||||
|
try {
|
||||||
|
const decryptedVerificationToken =
|
||||||
|
await encryptionService.decryptString(
|
||||||
|
privateKey,
|
||||||
|
encryptedVerificationToken
|
||||||
|
)
|
||||||
|
|
||||||
|
await sendVerificationTokenRaw(decryptedVerificationToken, [peerId])
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
receiveVerificationTokenRaw((decryptedVerificationToken, peerId) => {
|
||||||
|
const matchingPeer = peerList.find(peer => peer.peerId === peerId)
|
||||||
|
|
||||||
|
if (!matchingPeer) {
|
||||||
|
throw new Error(`peerId not found: ${peerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verificationToken, verificationTimer } = matchingPeer
|
||||||
|
|
||||||
|
if (decryptedVerificationToken !== verificationToken) {
|
||||||
|
updatePeer(peerId, {
|
||||||
|
verificationState: PeerVerificationState.UNVERIFIED,
|
||||||
|
verificationTimer: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
showAlert(
|
||||||
|
`Verification for ${getDisplayUsername(matchingPeer.userId)} failed`,
|
||||||
|
{
|
||||||
|
severity: 'error',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Verification token for peerId ${peerId} does not match. [expected: ${verificationToken}] [received: ${decryptedVerificationToken}]`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationTimer) {
|
||||||
|
clearTimeout(verificationTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePeer(peerId, {
|
||||||
|
verificationState: PeerVerificationState.VERIFIED,
|
||||||
|
verificationTimer: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { verifyPeer }
|
||||||
|
}
|
@ -21,29 +21,46 @@ import {
|
|||||||
isInlineMedia,
|
isInlineMedia,
|
||||||
FileOfferMetadata,
|
FileOfferMetadata,
|
||||||
TypingStatus,
|
TypingStatus,
|
||||||
|
Peer,
|
||||||
|
PeerVerificationState,
|
||||||
} from 'models/chat'
|
} from 'models/chat'
|
||||||
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
|
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
import { NotificationService } from 'services/Notification'
|
import { NotificationService } from 'services/Notification'
|
||||||
import { Audio as AudioService } from 'services/Audio'
|
import { Audio as AudioService } from 'services/Audio'
|
||||||
import { PeerRoom, PeerHookType } from 'services/PeerRoom'
|
import { PeerRoom, PeerHookType } from 'services/PeerRoom'
|
||||||
import { fileTransfer } from 'services/FileTransfer'
|
import { fileTransfer } from 'services/FileTransfer'
|
||||||
|
import {
|
||||||
|
AllowedKeyType,
|
||||||
|
encryptionService as encryptionServiceInstance,
|
||||||
|
} from 'services/Encryption'
|
||||||
|
|
||||||
import { messageTranscriptSizeLimit } from 'config/messaging'
|
import { messageTranscriptSizeLimit } from 'config/messaging'
|
||||||
|
|
||||||
|
import { usePeerVerification } from './usePeerVerification'
|
||||||
|
|
||||||
interface UseRoomConfig {
|
interface UseRoomConfig {
|
||||||
roomId: string
|
roomId: string
|
||||||
userId: string
|
userId: string
|
||||||
|
publicKey: CryptoKey
|
||||||
getUuid?: typeof uuid
|
getUuid?: typeof uuid
|
||||||
|
encryptionService?: typeof encryptionServiceInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserMetadata {
|
interface UserMetadata {
|
||||||
userId: string
|
userId: string
|
||||||
customUsername: string
|
customUsername: string
|
||||||
|
publicKeyString: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRoom(
|
export function useRoom(
|
||||||
{ password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig,
|
{ password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig,
|
||||||
{ roomId, userId, getUuid = uuid }: UseRoomConfig
|
{
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
publicKey,
|
||||||
|
getUuid = uuid,
|
||||||
|
encryptionService = encryptionServiceInstance,
|
||||||
|
}: UseRoomConfig
|
||||||
) {
|
) {
|
||||||
const isPrivate = password !== undefined
|
const isPrivate = password !== undefined
|
||||||
|
|
||||||
@ -209,6 +226,14 @@ export function useRoom(
|
|||||||
const [sendPeerInlineMedia, receivePeerInlineMedia] =
|
const [sendPeerInlineMedia, receivePeerInlineMedia] =
|
||||||
peerRoom.makeAction<UnsentInlineMedia>(PeerActions.MEDIA_MESSAGE)
|
peerRoom.makeAction<UnsentInlineMedia>(PeerActions.MEDIA_MESSAGE)
|
||||||
|
|
||||||
|
const { privateKey } = settingsContext.getUserSettings()
|
||||||
|
|
||||||
|
const { verifyPeer } = usePeerVerification({
|
||||||
|
peerRoom,
|
||||||
|
privateKey,
|
||||||
|
encryptionService,
|
||||||
|
})
|
||||||
|
|
||||||
const sendMessage = async (message: string) => {
|
const sendMessage = async (message: string) => {
|
||||||
if (isMessageSending) return
|
if (isMessageSending) return
|
||||||
|
|
||||||
@ -231,40 +256,51 @@ export function useRoom(
|
|||||||
setIsMessageSending(false)
|
setIsMessageSending(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
receivePeerMetadata(({ userId, customUsername }, peerId: string) => {
|
receivePeerMetadata(
|
||||||
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
|
async ({ userId, customUsername, publicKeyString }, peerId: string) => {
|
||||||
|
const publicKey = await encryptionService.parseCryptoKeyString(
|
||||||
|
publicKeyString,
|
||||||
|
AllowedKeyType.PUBLIC
|
||||||
|
)
|
||||||
|
|
||||||
if (peerIndex === -1) {
|
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
|
||||||
setPeerList([
|
|
||||||
...peerList,
|
if (peerIndex === -1) {
|
||||||
{
|
const newPeer: Peer = {
|
||||||
peerId,
|
peerId,
|
||||||
userId,
|
userId,
|
||||||
|
publicKey,
|
||||||
customUsername,
|
customUsername,
|
||||||
audioState: AudioState.STOPPED,
|
audioState: AudioState.STOPPED,
|
||||||
videoState: VideoState.STOPPED,
|
videoState: VideoState.STOPPED,
|
||||||
screenShareState: ScreenShareState.NOT_SHARING,
|
screenShareState: ScreenShareState.NOT_SHARING,
|
||||||
offeredFileId: null,
|
offeredFileId: null,
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
},
|
verificationToken: getUuid(),
|
||||||
])
|
encryptedVerificationToken: new ArrayBuffer(0),
|
||||||
|
verificationState: PeerVerificationState.VERIFYING,
|
||||||
|
verificationTimer: null,
|
||||||
|
}
|
||||||
|
|
||||||
sendTypingStatusChange({ isTyping }, peerId)
|
setPeerList([...peerList, newPeer])
|
||||||
} else {
|
sendTypingStatusChange({ isTyping }, peerId)
|
||||||
const oldUsername =
|
verifyPeer(newPeer)
|
||||||
peerList[peerIndex].customUsername || getPeerName(userId)
|
} else {
|
||||||
const newUsername = customUsername || getPeerName(userId)
|
const oldUsername =
|
||||||
|
peerList[peerIndex].customUsername || getPeerName(userId)
|
||||||
|
const newUsername = customUsername || getPeerName(userId)
|
||||||
|
|
||||||
const newPeerList = [...peerList]
|
const newPeerList = [...peerList]
|
||||||
const newPeer = { ...newPeerList[peerIndex], userId, customUsername }
|
const newPeer = { ...newPeerList[peerIndex], userId, customUsername }
|
||||||
newPeerList[peerIndex] = newPeer
|
newPeerList[peerIndex] = newPeer
|
||||||
setPeerList(newPeerList)
|
setPeerList(newPeerList)
|
||||||
|
|
||||||
if (oldUsername !== newUsername) {
|
if (oldUsername !== newUsername) {
|
||||||
showAlert(`${oldUsername} is now ${newUsername}`)
|
showAlert(`${oldUsername} is now ${newUsername}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
receiveMessageTranscript(transcript => {
|
receiveMessageTranscript(transcript => {
|
||||||
if (messageLog.length) return
|
if (messageLog.length) return
|
||||||
@ -303,8 +339,12 @@ export function useRoom(
|
|||||||
})
|
})
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
|
const publicKeyString = await encryptionService.stringifyCryptoKey(
|
||||||
|
publicKey
|
||||||
|
)
|
||||||
|
|
||||||
const promises: Promise<any>[] = [
|
const promises: Promise<any>[] = [
|
||||||
sendPeerMetadata({ userId, customUsername }, peerId),
|
sendPeerMetadata({ userId, customUsername, publicKeyString }, peerId),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!isPrivate) {
|
if (!isPrivate) {
|
||||||
@ -408,8 +448,18 @@ export function useRoom(
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendPeerMetadata({ customUsername, userId })
|
;(async () => {
|
||||||
}, [customUsername, userId, sendPeerMetadata])
|
const publicKeyString = await encryptionService.stringifyCryptoKey(
|
||||||
|
publicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
sendPeerMetadata({
|
||||||
|
customUsername,
|
||||||
|
userId,
|
||||||
|
publicKeyString,
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}, [customUsername, userId, sendPeerMetadata, publicKey, encryptionService])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
@ -8,10 +8,10 @@ import ListItem from '@mui/material/ListItem'
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
|
||||||
import { Username } from 'components/Username/Username'
|
import { UserInfo } from 'components/UserInfo'
|
||||||
import { AudioState, Peer } from 'models/chat'
|
import { AudioState, Peer } from 'models/chat'
|
||||||
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
|
import { PeerConnectionType } from 'services/PeerRoom'
|
||||||
import { TrackerConnection } from 'services/ConnectionTest/ConnectionTest'
|
import { TrackerConnection } from 'services/ConnectionTest'
|
||||||
|
|
||||||
import { PeerListHeader } from './PeerListHeader'
|
import { PeerListHeader } from './PeerListHeader'
|
||||||
import { PeerListItem } from './PeerListItem'
|
import { PeerListItem } from './PeerListItem'
|
||||||
@ -55,7 +55,7 @@ export const PeerList = ({
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
)}
|
)}
|
||||||
<ListItemText>
|
<ListItemText>
|
||||||
<Username userId={userId} />
|
<UserInfo userId={userId} />
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
{peerList.map((peer: Peer) => (
|
{peerList.map((peer: Peer) => (
|
||||||
|
@ -1,13 +1,24 @@
|
|||||||
import { Box } from '@mui/system'
|
import { useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
import ListItemText from '@mui/material/ListItemText'
|
import ListItemText from '@mui/material/ListItemText'
|
||||||
import SyncAltIcon from '@mui/icons-material/SyncAlt'
|
import SyncAltIcon from '@mui/icons-material/SyncAlt'
|
||||||
import NetworkPingIcon from '@mui/icons-material/NetworkPing'
|
import NetworkPingIcon from '@mui/icons-material/NetworkPing'
|
||||||
import ListItem from '@mui/material/ListItem'
|
import ListItem from '@mui/material/ListItem'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Dialog from '@mui/material/Dialog'
|
||||||
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
|
import DialogContent from '@mui/material/DialogContent'
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText'
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
|
import NoEncryptionIcon from '@mui/icons-material/NoEncryption'
|
||||||
|
import EnhancedEncryptionIcon from '@mui/icons-material/EnhancedEncryption'
|
||||||
|
|
||||||
import { AudioVolume } from 'components/AudioVolume'
|
import { AudioVolume } from 'components/AudioVolume'
|
||||||
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
import { Peer } from 'models/chat'
|
import { PublicKey } from 'components/PublicKey'
|
||||||
|
import { Peer, PeerVerificationState } from 'models/chat'
|
||||||
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
|
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
|
||||||
|
|
||||||
import { PeerDownloadFileButton } from './PeerDownloadFileButton'
|
import { PeerDownloadFileButton } from './PeerDownloadFileButton'
|
||||||
@ -17,60 +28,128 @@ interface PeerListItemProps {
|
|||||||
peerConnectionTypes: Record<string, PeerConnectionType>
|
peerConnectionTypes: Record<string, PeerConnectionType>
|
||||||
peerAudios: Record<string, HTMLAudioElement>
|
peerAudios: Record<string, HTMLAudioElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationStateDisplayMap = {
|
||||||
|
[PeerVerificationState.UNVERIFIED]: (
|
||||||
|
<Tooltip title="This person could not be verified with public-key cryptography. They may be misrepresenting themself. Be careful with what you share with them.">
|
||||||
|
<NoEncryptionIcon color="error" />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
[PeerVerificationState.VERIFIED]: (
|
||||||
|
<Tooltip title="This person has been verified with public-key cryptography">
|
||||||
|
<EnhancedEncryptionIcon color="success" />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
[PeerVerificationState.VERIFYING]: (
|
||||||
|
<Tooltip title="Attempting to verify this person...">
|
||||||
|
<CircularProgress size={16} sx={{ position: 'relative', top: 3 }} />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconRightPadding = 1
|
||||||
|
|
||||||
export const PeerListItem = ({
|
export const PeerListItem = ({
|
||||||
peer,
|
peer,
|
||||||
peerConnectionTypes,
|
peerConnectionTypes,
|
||||||
peerAudios,
|
peerAudios,
|
||||||
}: PeerListItemProps): JSX.Element => {
|
}: PeerListItemProps): JSX.Element => {
|
||||||
|
const [showPeerDialog, setShowPeerDialog] = useState(false)
|
||||||
|
|
||||||
const hasPeerConnection = peer.peerId in peerConnectionTypes
|
const hasPeerConnection = peer.peerId in peerConnectionTypes
|
||||||
|
|
||||||
const isPeerConnectionDirect =
|
const isPeerConnectionDirect =
|
||||||
peerConnectionTypes[peer.peerId] === PeerConnectionType.DIRECT
|
peerConnectionTypes[peer.peerId] === PeerConnectionType.DIRECT
|
||||||
|
|
||||||
|
const handleListItemClick = () => {
|
||||||
|
setShowPeerDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setShowPeerDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem key={peer.peerId} divider={true}>
|
<>
|
||||||
<PeerDownloadFileButton peer={peer} />
|
<ListItem
|
||||||
<ListItemText>
|
key={peer.peerId}
|
||||||
{hasPeerConnection ? (
|
divider={true}
|
||||||
<Tooltip
|
onClick={handleListItemClick}
|
||||||
title={
|
sx={{ cursor: 'pointer' }}
|
||||||
isPeerConnectionDirect ? (
|
>
|
||||||
<>
|
<PeerDownloadFileButton peer={peer} />
|
||||||
You are connected directly to{' '}
|
<ListItemText
|
||||||
<PeerNameDisplay
|
primaryTypographyProps={{
|
||||||
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
|
sx: { display: 'flex', alignContent: 'center' },
|
||||||
>
|
}}
|
||||||
{peer.userId}
|
>
|
||||||
</PeerNameDisplay>
|
{hasPeerConnection ? (
|
||||||
</>
|
<Tooltip
|
||||||
) : (
|
title={
|
||||||
<>
|
isPeerConnectionDirect ? (
|
||||||
You are connected to{' '}
|
<>
|
||||||
<PeerNameDisplay
|
You are connected directly to{' '}
|
||||||
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
|
<PeerNameDisplay
|
||||||
>
|
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
|
||||||
{peer.userId}
|
>
|
||||||
</PeerNameDisplay>{' '}
|
{peer.userId}
|
||||||
via a relay server. Your connection is still private and
|
</PeerNameDisplay>
|
||||||
encrypted, but performance may be degraded.
|
</>
|
||||||
</>
|
) : (
|
||||||
)
|
<>
|
||||||
}
|
You are connected to{' '}
|
||||||
|
<PeerNameDisplay
|
||||||
|
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
|
||||||
|
>
|
||||||
|
{peer.userId}
|
||||||
|
</PeerNameDisplay>{' '}
|
||||||
|
via a relay server. Your connection is still private and
|
||||||
|
encrypted, but performance may be degraded.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ pr: iconRightPadding, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{isPeerConnectionDirect ? (
|
||||||
|
<SyncAltIcon color="success" />
|
||||||
|
) : (
|
||||||
|
<NetworkPingIcon color="warning" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ pr: iconRightPadding, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<Box component="span" sx={{ pr: 1, cursor: 'pointer' }}>
|
{verificationStateDisplayMap[peer.verificationState]}
|
||||||
{isPeerConnectionDirect ? (
|
</Box>
|
||||||
<SyncAltIcon color="success" />
|
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
|
||||||
) : (
|
{peer.peerId in peerAudios && (
|
||||||
<NetworkPingIcon color="warning" />
|
<AudioVolume audioEl={peerAudios[peer.peerId]} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</ListItemText>
|
||||||
</Tooltip>
|
</ListItem>
|
||||||
) : null}
|
<Dialog open={showPeerDialog} onClose={handleDialogClose}>
|
||||||
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
{peer.peerId in peerAudios && (
|
{verificationStateDisplayMap[peer.verificationState]}
|
||||||
<AudioVolume audioEl={peerAudios[peer.peerId]} />
|
<Box component="span" sx={{ ml: 1 }}>
|
||||||
)}
|
<PeerNameDisplay sx={{ fontSize: 'inherit' }}>
|
||||||
</ListItemText>
|
{peer.userId}
|
||||||
</ListItem>
|
</PeerNameDisplay>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>Their public key:</DialogContentText>
|
||||||
|
<PublicKey publicKey={peer.publicKey} />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleDialogClose}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,8 @@ import CloseIcon from '@mui/icons-material/Close'
|
|||||||
|
|
||||||
import { AlertOptions } from 'models/shell'
|
import { AlertOptions } from 'models/shell'
|
||||||
import { useEffect, useState, SyntheticEvent } from 'react'
|
import { useEffect, useState, SyntheticEvent } from 'react'
|
||||||
import { encodePassword, sleep } from 'utils'
|
import { sleep } from 'utils'
|
||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
export interface RoomShareDialogProps {
|
export interface RoomShareDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@ -50,22 +51,30 @@ export function RoomShareDialog(props: RoomShareDialogProps) {
|
|||||||
const url = window.location.href.split('#')[0]
|
const url = window.location.href.split('#')[0]
|
||||||
|
|
||||||
const copyWithPass = async () => {
|
const copyWithPass = async () => {
|
||||||
const encoded = await encodePassword(props.roomId, password)
|
const encoded = await encryptionService.encodePassword(
|
||||||
|
props.roomId,
|
||||||
|
password
|
||||||
|
)
|
||||||
|
|
||||||
if (encoded === props.password) {
|
if (encoded === props.password) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('secret', props.password)
|
params.set('secret', props.password)
|
||||||
|
|
||||||
await props.copyToClipboard(
|
await props.copyToClipboard(
|
||||||
`${url}#${params}`,
|
`${url}#${params}`,
|
||||||
'Private room URL with password copied to clipboard',
|
'Private room URL with password copied to clipboard',
|
||||||
'warning'
|
'warning'
|
||||||
)
|
)
|
||||||
|
|
||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
} else {
|
||||||
setPassThrottled(true)
|
setPassThrottled(true)
|
||||||
props.showAlert('Incorrect password entered. Please wait 2s to retry.', {
|
props.showAlert('Incorrect password entered. Please wait 2s to retry.', {
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
})
|
})
|
||||||
|
|
||||||
await sleep(2000)
|
await sleep(2000)
|
||||||
|
|
||||||
setPassThrottled(false)
|
setPassThrottled(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,11 +87,13 @@ export function RoomShareDialog(props: RoomShareDialogProps) {
|
|||||||
: 'Current URL copied to clipboard',
|
: 'Current URL copied to clipboard',
|
||||||
'success'
|
'success'
|
||||||
)
|
)
|
||||||
|
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFormSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
|
const handleFormSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!passThrottled) copyWithPass()
|
if (!passThrottled) copyWithPass()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,17 +109,19 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
|||||||
|
|
||||||
const updatePeer = useCallback(
|
const updatePeer = useCallback(
|
||||||
(peerId: string, updatedProperties: Partial<Peer>) => {
|
(peerId: string, updatedProperties: Partial<Peer>) => {
|
||||||
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
|
setPeerList(peerList => {
|
||||||
const doesPeerExist = peerIndex !== -1
|
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
|
||||||
|
const doesPeerExist = peerIndex !== -1
|
||||||
|
|
||||||
if (!doesPeerExist) return
|
if (!doesPeerExist) return peerList
|
||||||
|
|
||||||
const peerListClone = [...peerList]
|
const peerListClone = [...peerList]
|
||||||
const peer = peerList[peerIndex]
|
const peer = peerList[peerIndex]
|
||||||
peerListClone[peerIndex] = { ...peer, ...updatedProperties }
|
peerListClone[peerIndex] = { ...peer, ...updatedProperties }
|
||||||
setPeerList(peerListClone)
|
return peerListClone
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[peerList]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const shellContextValue = useMemo(
|
const shellContextValue = useMemo(
|
||||||
|
129
src/components/UserInfo/UserInfo.tsx
Normal file
129
src/components/UserInfo/UserInfo.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useContext, ChangeEvent, SyntheticEvent } from 'react'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import FormControl from '@mui/material/FormControl'
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import Dialog from '@mui/material/Dialog'
|
||||||
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
|
import DialogContent from '@mui/material/DialogContent'
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText'
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import useTheme from '@mui/material/styles/useTheme'
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
|
||||||
|
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
import { getPeerName } from 'components/PeerNameDisplay'
|
||||||
|
import { SettingsContext } from 'contexts/SettingsContext'
|
||||||
|
import { PublicKey } from 'components/PublicKey'
|
||||||
|
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
|
|
||||||
|
interface UserInfoProps {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCustomUsernameLength = 30
|
||||||
|
|
||||||
|
export const UserInfo = ({ userId }: UserInfoProps) => {
|
||||||
|
const theme = useTheme()
|
||||||
|
const userName = getPeerName(userId)
|
||||||
|
|
||||||
|
const { customUsername, setCustomUsername, showAlert } =
|
||||||
|
useContext(ShellContext)
|
||||||
|
const { getUserSettings } = useContext(SettingsContext)
|
||||||
|
const [inflightCustomUsername, setInflightCustomUsername] =
|
||||||
|
useState(customUsername)
|
||||||
|
const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const { publicKey } = getUserSettings()
|
||||||
|
|
||||||
|
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInflightCustomUsername(evt.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCustomUsername = () => {
|
||||||
|
const trimmedUsername = inflightCustomUsername.trim()
|
||||||
|
setCustomUsername(trimmedUsername)
|
||||||
|
|
||||||
|
if (trimmedUsername.length) {
|
||||||
|
showAlert(`Username changed to "${trimmedUsername}"`, {
|
||||||
|
severity: 'success',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showAlert(`Username reset`, { severity: 'success' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (evt: SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
updateCustomUsername()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
updateCustomUsername()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInfoButtonClick = () => {
|
||||||
|
setIsInfoDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInfoDialogClose = () => {
|
||||||
|
setIsInfoDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<FormControl sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
variant="outlined"
|
||||||
|
label={`${userName}`}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
value={inflightCustomUsername}
|
||||||
|
inputProps={{ maxLength: maxCustomUsernameLength }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Reveal your user info">
|
||||||
|
<IconButton
|
||||||
|
sx={{
|
||||||
|
ml: 1.5,
|
||||||
|
color: theme.palette.action.active,
|
||||||
|
}}
|
||||||
|
onClick={handleInfoButtonClick}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon fontSize="large" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<FormHelperText>Your username</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</form>
|
||||||
|
<Dialog open={isInfoDialogOpen} onClose={handleInfoDialogClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box component="span">
|
||||||
|
<PeerNameDisplay sx={{ fontSize: 'inherit' }}>
|
||||||
|
{userId}
|
||||||
|
</PeerNameDisplay>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Your public key (generated locally):
|
||||||
|
</DialogContentText>
|
||||||
|
<PublicKey publicKey={publicKey} />
|
||||||
|
<DialogContentText>
|
||||||
|
Your private key, which was also generated locally, is hidden and
|
||||||
|
only exists on your device.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleInfoDialogClose}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
1
src/components/UserInfo/index.ts
Normal file
1
src/components/UserInfo/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './UserInfo'
|
@ -1,65 +0,0 @@
|
|||||||
import { useState, useContext, ChangeEvent, SyntheticEvent } from 'react'
|
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import FormControl from '@mui/material/FormControl'
|
|
||||||
import FormHelperText from '@mui/material/FormHelperText'
|
|
||||||
|
|
||||||
import { ShellContext } from 'contexts/ShellContext'
|
|
||||||
import { getPeerName } from 'components/PeerNameDisplay/getPeerName'
|
|
||||||
|
|
||||||
interface UsernameProps {
|
|
||||||
userId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxCustomUsernameLength = 30
|
|
||||||
|
|
||||||
export const Username = ({ userId }: UsernameProps) => {
|
|
||||||
const userName = getPeerName(userId)
|
|
||||||
|
|
||||||
const { customUsername, setCustomUsername, showAlert } =
|
|
||||||
useContext(ShellContext)
|
|
||||||
const [inflightCustomUsername, setInflightCustomUsername] =
|
|
||||||
useState(customUsername)
|
|
||||||
|
|
||||||
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setInflightCustomUsername(evt.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCustomUsername = () => {
|
|
||||||
const trimmedUsername = inflightCustomUsername.trim()
|
|
||||||
setCustomUsername(trimmedUsername)
|
|
||||||
|
|
||||||
if (trimmedUsername.length) {
|
|
||||||
showAlert(`Username changed to "${trimmedUsername}"`, {
|
|
||||||
severity: 'success',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showAlert(`Username reset`, { severity: 'success' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (evt: SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
evt.preventDefault()
|
|
||||||
updateCustomUsername()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
updateCustomUsername()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<FormControl sx={{ width: '100%' }}>
|
|
||||||
<TextField
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
variant="outlined"
|
|
||||||
label={`${userName}`}
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
value={inflightCustomUsername}
|
|
||||||
inputProps={{ maxLength: maxCustomUsernameLength }}
|
|
||||||
/>
|
|
||||||
<FormHelperText>Your username</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export * from './Username'
|
|
@ -1,2 +1,3 @@
|
|||||||
export const messageCharacterSizeLimit = 10_000
|
export const messageCharacterSizeLimit = 10_000
|
||||||
export const messageTranscriptSizeLimit = 150
|
export const messageTranscriptSizeLimit = 150
|
||||||
|
export const verificationTimeout = 10_000
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { createContext } from 'react'
|
import { createContext } from 'react'
|
||||||
|
|
||||||
import { ColorMode, UserSettings } from 'models/settings'
|
import { ColorMode, UserSettings } from 'models/settings'
|
||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
export interface SettingsContextProps {
|
export interface SettingsContextProps {
|
||||||
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
|
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
|
||||||
@ -16,5 +17,7 @@ export const SettingsContext = createContext<SettingsContextProps>({
|
|||||||
playSoundOnNewMessage: true,
|
playSoundOnNewMessage: true,
|
||||||
showNotificationOnNewMessage: true,
|
showNotificationOnNewMessage: true,
|
||||||
showActiveTypingStatus: true,
|
showActiveTypingStatus: true,
|
||||||
|
publicKey: encryptionService.cryptoKeyStub,
|
||||||
|
privateKey: encryptionService.cryptoKeyStub,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
@ -2,12 +2,12 @@ import './polyfills'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import 'typeface-roboto'
|
import 'typeface-roboto'
|
||||||
|
|
||||||
import 'index.sass'
|
import './index.sass'
|
||||||
import Bootstrap from 'Bootstrap'
|
import Init from './Init'
|
||||||
import reportWebVitals from 'reportWebVitals'
|
import reportWebVitals from './reportWebVitals'
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||||
root.render(<Bootstrap />)
|
root.render(<Init />)
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
// If you want to start measuring performance in your app, pass a function
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
@ -41,15 +41,26 @@ export enum ScreenShareState {
|
|||||||
NOT_SHARING = 'NOT_SHARING',
|
NOT_SHARING = 'NOT_SHARING',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PeerVerificationState {
|
||||||
|
VERIFYING,
|
||||||
|
UNVERIFIED,
|
||||||
|
VERIFIED,
|
||||||
|
}
|
||||||
|
|
||||||
export interface Peer {
|
export interface Peer {
|
||||||
peerId: string
|
peerId: string
|
||||||
userId: string
|
userId: string
|
||||||
|
publicKey: CryptoKey
|
||||||
customUsername: string
|
customUsername: string
|
||||||
audioState: AudioState
|
audioState: AudioState
|
||||||
videoState: VideoState
|
videoState: VideoState
|
||||||
screenShareState: ScreenShareState
|
screenShareState: ScreenShareState
|
||||||
offeredFileId: string | null
|
offeredFileId: string | null
|
||||||
isTyping: boolean
|
isTyping: boolean
|
||||||
|
verificationToken: string
|
||||||
|
encryptedVerificationToken: ArrayBuffer
|
||||||
|
verificationState: PeerVerificationState
|
||||||
|
verificationTimer: NodeJS.Timeout | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isMessageReceived = (
|
export const isMessageReceived = (
|
||||||
|
@ -9,4 +9,6 @@ export enum PeerActions {
|
|||||||
SCREEN_SHARE = 'SCREEN_SHARE',
|
SCREEN_SHARE = 'SCREEN_SHARE',
|
||||||
FILE_OFFER = 'FILE_OFFER',
|
FILE_OFFER = 'FILE_OFFER',
|
||||||
TYPING_STATUS_CHANGE = 'TYPNG_CHANGE',
|
TYPING_STATUS_CHANGE = 'TYPNG_CHANGE',
|
||||||
|
VERIFICATION_TOKEN_ENCRYPTED = 'V_TKN_ENC',
|
||||||
|
VERIFICATION_TOKEN_RAW = 'V_TKN_RAW',
|
||||||
}
|
}
|
||||||
|
@ -16,4 +16,6 @@ export interface UserSettings {
|
|||||||
playSoundOnNewMessage: boolean
|
playSoundOnNewMessage: boolean
|
||||||
showNotificationOnNewMessage: boolean
|
showNotificationOnNewMessage: boolean
|
||||||
showActiveTypingStatus: boolean
|
showActiveTypingStatus: boolean
|
||||||
|
publicKey: CryptoKeyPair['publicKey']
|
||||||
|
privateKey: CryptoKeyPair['privateKey']
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,12 @@ Private rooms can only be joined by peers with a matching password. The password
|
|||||||
|
|
||||||
To connect to others, share the room URL with a secure tool such as [Burner Note](https://burnernote.com/) or [Yopass](https://yopass.se/). You will be notified when others join the room.
|
To connect to others, share the room URL with a secure tool such as [Burner Note](https://burnernote.com/) or [Yopass](https://yopass.se/). You will be notified when others join the room.
|
||||||
|
|
||||||
|
##### Peer verification
|
||||||
|
|
||||||
|
When you connect with a peer, Chitchatter automatically attempts to use [public-key cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography) to verify them. You can see everyone's public keys in the peer list. Feel free to share your public key with others (it is not sensitive information) so that they can uniquely identify you.
|
||||||
|
|
||||||
|
All public and private keys are generated locally. Your private key is never sent to any peer or server.
|
||||||
|
|
||||||
##### Community rooms
|
##### Community rooms
|
||||||
|
|
||||||
There is [a public list of community rooms](https://github.com/jeremyckahn/chitchatter/wiki/Chitchatter-Community-Rooms) that you can join to discuss various topics.
|
There is [a public list of community rooms](https://github.com/jeremyckahn/chitchatter/wiki/Chitchatter-Community-Rooms) that you can join to discuss various topics.
|
||||||
|
@ -4,8 +4,8 @@ import { useParams } from 'react-router-dom'
|
|||||||
|
|
||||||
import { ShellContext } from 'contexts/ShellContext'
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
import { NotificationService } from 'services/Notification'
|
import { NotificationService } from 'services/Notification'
|
||||||
import { PasswordPrompt } from 'components/PasswordPrompt/PasswordPrompt'
|
import { PasswordPrompt } from 'components/PasswordPrompt'
|
||||||
import { encodePassword } from 'utils'
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
interface PublicRoomProps {
|
interface PublicRoomProps {
|
||||||
userId: string
|
userId: string
|
||||||
@ -30,7 +30,8 @@ export function PrivateRoom({ userId }: PublicRoomProps) {
|
|||||||
}, [roomId, setTitle])
|
}, [roomId, setTitle])
|
||||||
|
|
||||||
const handlePasswordEntered = async (password: string) => {
|
const handlePasswordEntered = async (password: string) => {
|
||||||
if (password.length !== 0) setSecret(await encodePassword(roomId, password))
|
if (password.length !== 0)
|
||||||
|
setSecret(await encryptionService.encodePassword(roomId, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlParams.has('pwd') && !urlParams.has('secret'))
|
if (urlParams.has('pwd') && !urlParams.has('secret'))
|
||||||
|
1
src/services/ConnectionTest/index.ts
Normal file
1
src/services/ConnectionTest/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './ConnectionTest'
|
113
src/services/Encryption/Encryption.ts
Normal file
113
src/services/Encryption/Encryption.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// NOTE: Much of what's here is derived from various ChatGPT responses:
|
||||||
|
//
|
||||||
|
// - https://gist.github.com/jeremyckahn/cbb6107e7de6c83b620960a19266055e
|
||||||
|
// - https://gist.github.com/jeremyckahn/c49ca17a849ecf35c5f957ffde956cf4
|
||||||
|
|
||||||
|
export enum AllowedKeyType {
|
||||||
|
PUBLIC,
|
||||||
|
PRIVATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
|
||||||
|
const binary = String.fromCharCode(...new Uint8Array(buffer))
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64ToArrayBuffer = (base64: string) => {
|
||||||
|
const binaryString = atob(base64)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const algorithmName = 'RSA-OAEP'
|
||||||
|
|
||||||
|
const algorithmHash = 'SHA-256'
|
||||||
|
|
||||||
|
export class EncryptionService {
|
||||||
|
cryptoKeyStub: CryptoKey = {
|
||||||
|
algorithm: { name: 'STUB-ALGORITHM' },
|
||||||
|
extractable: false,
|
||||||
|
type: 'private',
|
||||||
|
usages: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this configurable
|
||||||
|
generateKeyPair = async (): Promise<CryptoKeyPair> => {
|
||||||
|
const keyPair = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: algorithmName,
|
||||||
|
hash: algorithmHash,
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
)
|
||||||
|
|
||||||
|
return keyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
encodePassword = async (roomId: string, password: string) => {
|
||||||
|
const data = new TextEncoder().encode(`${roomId}_${password}`)
|
||||||
|
const digest = await window.crypto.subtle.digest('SHA-256', data)
|
||||||
|
const bytes = new Uint8Array(digest)
|
||||||
|
const encodedPassword = window.btoa(String.fromCharCode(...bytes))
|
||||||
|
|
||||||
|
return encodedPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
stringifyCryptoKey = async (cryptoKey: CryptoKey) => {
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey(
|
||||||
|
cryptoKey.type === 'public' ? 'spki' : 'pkcs8',
|
||||||
|
cryptoKey
|
||||||
|
)
|
||||||
|
|
||||||
|
const exportedKeyAsString = arrayBufferToBase64(exportedKey)
|
||||||
|
|
||||||
|
return exportedKeyAsString
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCryptoKeyString = async (keyString: string, type: AllowedKeyType) => {
|
||||||
|
const importedKey = await window.crypto.subtle.importKey(
|
||||||
|
type === AllowedKeyType.PUBLIC ? 'spki' : 'pkcs8',
|
||||||
|
base64ToArrayBuffer(keyString),
|
||||||
|
{
|
||||||
|
name: algorithmName,
|
||||||
|
hash: algorithmHash,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
type === AllowedKeyType.PUBLIC ? ['encrypt'] : ['decrypt']
|
||||||
|
)
|
||||||
|
|
||||||
|
return importedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptString = async (publicKey: CryptoKey, plaintext: string) => {
|
||||||
|
const encodedText = new TextEncoder().encode(plaintext)
|
||||||
|
const encryptedData = await crypto.subtle.encrypt(
|
||||||
|
algorithmName,
|
||||||
|
publicKey,
|
||||||
|
encodedText
|
||||||
|
)
|
||||||
|
|
||||||
|
return encryptedData
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptString = async (privateKey: CryptoKey, encryptedData: ArrayBuffer) => {
|
||||||
|
const decryptedArrayBuffer = await crypto.subtle.decrypt(
|
||||||
|
algorithmName,
|
||||||
|
privateKey,
|
||||||
|
encryptedData
|
||||||
|
)
|
||||||
|
|
||||||
|
const decryptedString = new TextDecoder().decode(decryptedArrayBuffer)
|
||||||
|
|
||||||
|
return decryptedString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptionService = new EncryptionService()
|
1
src/services/Encryption/index.ts
Normal file
1
src/services/Encryption/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Encryption'
|
61
src/services/Serialization/Serialization.ts
Normal file
61
src/services/Serialization/Serialization.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { UserSettings } from 'models/settings'
|
||||||
|
import { AllowedKeyType, encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
|
export interface SerializedUserSettings
|
||||||
|
extends Omit<UserSettings, 'publicKey' | 'privateKey'> {
|
||||||
|
publicKey: string
|
||||||
|
privateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SerializationService {
|
||||||
|
serializeUserSettings = async (
|
||||||
|
userSettings: UserSettings
|
||||||
|
): Promise<SerializedUserSettings> => {
|
||||||
|
const {
|
||||||
|
publicKey: publicCryptoKey,
|
||||||
|
privateKey: privateCryptoKey,
|
||||||
|
...userSettingsRest
|
||||||
|
} = userSettings
|
||||||
|
|
||||||
|
const publicKey = await encryptionService.stringifyCryptoKey(
|
||||||
|
publicCryptoKey
|
||||||
|
)
|
||||||
|
|
||||||
|
const privateKey = await encryptionService.stringifyCryptoKey(
|
||||||
|
privateCryptoKey
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userSettingsRest,
|
||||||
|
publicKey,
|
||||||
|
privateKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializeUserSettings = async (
|
||||||
|
serializedUserSettings: SerializedUserSettings
|
||||||
|
): Promise<UserSettings> => {
|
||||||
|
const {
|
||||||
|
publicKey: publicCryptoKeyString,
|
||||||
|
privateKey: privateCryptoKeyString,
|
||||||
|
...userSettingsForIndexedDbRest
|
||||||
|
} = serializedUserSettings
|
||||||
|
|
||||||
|
const publicKey = await encryptionService.parseCryptoKeyString(
|
||||||
|
publicCryptoKeyString,
|
||||||
|
AllowedKeyType.PUBLIC
|
||||||
|
)
|
||||||
|
const privateKey = await encryptionService.parseCryptoKeyString(
|
||||||
|
privateCryptoKeyString,
|
||||||
|
AllowedKeyType.PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userSettingsForIndexedDbRest,
|
||||||
|
publicKey,
|
||||||
|
privateKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializationService = new SerializationService()
|
1
src/services/Serialization/index.ts
Normal file
1
src/services/Serialization/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Serialization'
|
15
src/test-utils/mocks/mockEncryptionService.ts
Normal file
15
src/test-utils/mocks/mockEncryptionService.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
|
export const mockEncryptionService = encryptionService
|
||||||
|
|
||||||
|
mockEncryptionService.generateKeyPair = jest.fn(async () => ({
|
||||||
|
publicKey: encryptionService.cryptoKeyStub,
|
||||||
|
privateKey: encryptionService.cryptoKeyStub,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mockEncryptionService.encodePassword = async () => ''
|
||||||
|
|
||||||
|
mockEncryptionService.stringifyCryptoKey = async () => ''
|
||||||
|
|
||||||
|
mockEncryptionService.parseCryptoKeyString = async () =>
|
||||||
|
encryptionService.cryptoKeyStub
|
35
src/test-utils/mocks/mockSerializationService.ts
Normal file
35
src/test-utils/mocks/mockSerializationService.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { UserSettings } from 'models/settings'
|
||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
import {
|
||||||
|
serializationService,
|
||||||
|
SerializedUserSettings,
|
||||||
|
} from 'services/Serialization'
|
||||||
|
|
||||||
|
export const mockSerializedPublicKey = 'public key'
|
||||||
|
export const mockSerializedPrivateKey = 'private key'
|
||||||
|
|
||||||
|
export const mockSerializationService = serializationService
|
||||||
|
|
||||||
|
mockSerializationService.serializeUserSettings = async (
|
||||||
|
userSettings: UserSettings
|
||||||
|
) => {
|
||||||
|
const { publicKey, privateKey, ...userSettingsRest } = userSettings
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: mockSerializedPublicKey,
|
||||||
|
privateKey: mockSerializedPrivateKey,
|
||||||
|
...userSettingsRest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSerializationService.deserializeUserSettings = async (
|
||||||
|
serializedUserSettings: SerializedUserSettings
|
||||||
|
) => {
|
||||||
|
const { publicKey, privateKey, ...userSettingsRest } = serializedUserSettings
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: encryptionService.cryptoKeyStub,
|
||||||
|
privateKey: encryptionService.cryptoKeyStub,
|
||||||
|
...userSettingsRest,
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { SettingsContextProps } from 'contexts/SettingsContext'
|
import { SettingsContextProps } from 'contexts/SettingsContext'
|
||||||
import { ColorMode, UserSettings } from 'models/settings'
|
import { ColorMode, UserSettings } from 'models/settings'
|
||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
export const userSettingsContextStubFactory = (
|
export const userSettingsContextStubFactory = (
|
||||||
userSettingsOverrides: Partial<UserSettings> = {}
|
userSettingsOverrides: Partial<UserSettings> = {}
|
||||||
@ -13,6 +14,8 @@ export const userSettingsContextStubFactory = (
|
|||||||
playSoundOnNewMessage: true,
|
playSoundOnNewMessage: true,
|
||||||
showNotificationOnNewMessage: true,
|
showNotificationOnNewMessage: true,
|
||||||
showActiveTypingStatus: true,
|
showActiveTypingStatus: true,
|
||||||
|
publicKey: encryptionService.cryptoKeyStub,
|
||||||
|
privateKey: encryptionService.cryptoKeyStub,
|
||||||
...userSettingsOverrides,
|
...userSettingsOverrides,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
18
src/test-utils/stubs/userSettings.ts
Normal file
18
src/test-utils/stubs/userSettings.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ColorMode, UserSettings } from 'models/settings'
|
||||||
|
import { encryptionService } from 'services/Encryption'
|
||||||
|
|
||||||
|
export const userSettingsStubFactory = (
|
||||||
|
overrides: Partial<UserSettings> = {}
|
||||||
|
): UserSettings => {
|
||||||
|
return {
|
||||||
|
userId: '1234-abcd',
|
||||||
|
customUsername: '',
|
||||||
|
colorMode: ColorMode.DARK,
|
||||||
|
playSoundOnNewMessage: true,
|
||||||
|
showNotificationOnNewMessage: true,
|
||||||
|
showActiveTypingStatus: true,
|
||||||
|
publicKey: encryptionService.cryptoKeyStub,
|
||||||
|
privateKey: encryptionService.cryptoKeyStub,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
@ -14,10 +14,3 @@ export const isRecord = (variable: any): variable is Record<string, any> => {
|
|||||||
export const isError = (e: any): e is Error => {
|
export const isError = (e: any): e is Error => {
|
||||||
return e instanceof Error
|
return e instanceof Error
|
||||||
}
|
}
|
||||||
|
|
||||||
export const encodePassword = async (roomId: string, password: string) => {
|
|
||||||
const data = new TextEncoder().encode(`${roomId}_${password}`)
|
|
||||||
const digest = await window.crypto.subtle.digest('SHA-256', data)
|
|
||||||
const bytes = new Uint8Array(digest)
|
|
||||||
return window.btoa(String.fromCharCode(...bytes))
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user