feat(verification): [closes #209] Verified peers (#216)

* 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:
Jeremy Kahn 2023-12-09 17:47:05 -06:00 committed by GitHub
parent c19bbbeee2
commit 6cbfaacf1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 998 additions and 214 deletions

View File

@ -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

View File

@ -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,
@ -27,6 +33,8 @@ const renderBootstrap = async (overrides: BootstrapProps = {}) => {
render(
<Bootstrap
persistedStorage={mockPersistedStorage as any as typeof localforage}
initialUserSettings={userSettingsStub}
serializationService={mockSerializationService}
{...overrides}
/>
)
@ -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, {
@ -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()
})

View File

@ -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'
@ -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
@ -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),
[]
@ -86,14 +92,8 @@ 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 = () => {
@ -101,17 +101,20 @@ function Bootstrap({
}
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(() => {
@ -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> => {
@ -152,12 +165,9 @@ function Bootstrap({
const computedUserSettings = await computeUserSettings()
setUserSettings(computedUserSettings)
if (persistedUserSettings === null) {
await persistUserSettings(computedUserSettings)
}
setHasLoadedSettings(true)
await persistUserSettings(computedUserSettings)
})()
}, [
hasLoadedSettings,
@ -166,6 +176,8 @@ function Bootstrap({
userId,
queryParams,
persistUserSettings,
serializationService,
initialUserSettings,
])
useEffect(() => {
@ -245,12 +257,10 @@ function Bootstrap({
</Routes>
</Shell>
) : (
<></>
<WholePageLoading />
)}
</SettingsContext.Provider>
</StorageContext.Provider>
</Router>
)
}
export default Bootstrap

80
src/Init.tsx Normal file
View 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

View 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>
)
}

View File

@ -0,0 +1 @@
export * from './Loading'

View 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>
)
}

View File

@ -0,0 +1 @@
export * from './PublicKey'

View File

@ -4,10 +4,11 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
import { mockEncryptionService } from 'test-utils/mocks/mockEncryptionService'
import { SettingsContext } from 'contexts/SettingsContext'
import { Room } from './'
import { Room, RoomProps } from './'
const mockUserId = 'user-id'
const mockRoomId = 'room-123'
@ -54,11 +55,15 @@ const RouteStub = ({ children }: PropsWithChildren) => {
jest.useFakeTimers().setSystemTime(100)
const RoomStub = (props: RoomProps) => {
return <Room encryptionService={mockEncryptionService} {...props} />
}
describe('Room', () => {
test('is available', () => {
render(
<RouteStub>
<Room userId={mockUserId} roomId={mockRoomId} />
<RoomStub userId={mockUserId} roomId={mockRoomId} />
</RouteStub>
)
})
@ -66,7 +71,7 @@ describe('Room', () => {
test('send button is disabled', () => {
render(
<RouteStub>
<Room userId={mockUserId} roomId={mockRoomId} />
<RoomStub userId={mockUserId} roomId={mockRoomId} />
</RouteStub>
)
@ -77,7 +82,7 @@ describe('Room', () => {
test('inputting text enabled send button', async () => {
render(
<RouteStub>
<Room userId={mockUserId} roomId={mockRoomId} />
<RoomStub userId={mockUserId} roomId={mockRoomId} />
</RouteStub>
)
@ -94,7 +99,7 @@ describe('Room', () => {
test('sending a message clears the text input', async () => {
render(
<RouteStub>
<Room userId={mockUserId} roomId={mockRoomId} />
<RoomStub userId={mockUserId} roomId={mockRoomId} />
</RouteStub>
)
@ -115,7 +120,7 @@ describe('Room', () => {
test('message is sent to peer', async () => {
render(
<RouteStub>
<Room
<RoomStub
getUuid={mockGetUuid.mockImplementation(() => 'abc123')}
userId={mockUserId}
roomId={mockRoomId}

View File

@ -12,7 +12,7 @@ import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext'
import { MessageForm } from 'components/MessageForm'
import { ChatTranscript } from 'components/ChatTranscript'
import { encryptionService as encryptionServiceInstance } from 'services/Encryption'
import { SettingsContext } from 'contexts/SettingsContext'
import { useRoom } from './useRoom'
@ -30,18 +30,21 @@ export interface RoomProps {
password?: string
roomId: string
userId: string
encryptionService?: typeof encryptionServiceInstance
}
export function Room({
appId = `${encodeURI(window.location.origin)}_${process.env.REACT_APP_NAME}`,
getUuid = uuid,
encryptionService = encryptionServiceInstance,
roomId,
password,
userId,
}: RoomProps) {
const theme = useTheme()
const settingsContext = useContext(SettingsContext)
const { showActiveTypingStatus } = settingsContext.getUserSettings()
const { showActiveTypingStatus, publicKey } =
settingsContext.getUserSettings()
const {
isMessageSending,
handleInlineMediaUpload,
@ -63,6 +66,8 @@ export function Room({
roomId,
userId,
getUuid,
publicKey,
encryptionService,
}
)

View 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 }
}

View File

@ -21,29 +21,46 @@ import {
isInlineMedia,
FileOfferMetadata,
TypingStatus,
Peer,
PeerVerificationState,
} from 'models/chat'
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
import { NotificationService } from 'services/Notification'
import { Audio as AudioService } from 'services/Audio'
import { PeerRoom, PeerHookType } from 'services/PeerRoom'
import { fileTransfer } from 'services/FileTransfer'
import {
AllowedKeyType,
encryptionService as encryptionServiceInstance,
} from 'services/Encryption'
import { messageTranscriptSizeLimit } from 'config/messaging'
import { usePeerVerification } from './usePeerVerification'
interface UseRoomConfig {
roomId: string
userId: string
publicKey: CryptoKey
getUuid?: typeof uuid
encryptionService?: typeof encryptionServiceInstance
}
interface UserMetadata {
userId: string
customUsername: string
publicKeyString: string
}
export function useRoom(
{ password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig,
{ roomId, userId, getUuid = uuid }: UseRoomConfig
{
roomId,
userId,
publicKey,
getUuid = uuid,
encryptionService = encryptionServiceInstance,
}: UseRoomConfig
) {
const isPrivate = password !== undefined
@ -209,6 +226,14 @@ export function useRoom(
const [sendPeerInlineMedia, receivePeerInlineMedia] =
peerRoom.makeAction<UnsentInlineMedia>(PeerActions.MEDIA_MESSAGE)
const { privateKey } = settingsContext.getUserSettings()
const { verifyPeer } = usePeerVerification({
peerRoom,
privateKey,
encryptionService,
})
const sendMessage = async (message: string) => {
if (isMessageSending) return
@ -231,40 +256,51 @@ export function useRoom(
setIsMessageSending(false)
}
receivePeerMetadata(({ userId, customUsername }, peerId: string) => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
receivePeerMetadata(
async ({ userId, customUsername, publicKeyString }, peerId: string) => {
const publicKey = await encryptionService.parseCryptoKeyString(
publicKeyString,
AllowedKeyType.PUBLIC
)
if (peerIndex === -1) {
setPeerList([
...peerList,
{
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
if (peerIndex === -1) {
const newPeer: Peer = {
peerId,
userId,
publicKey,
customUsername,
audioState: AudioState.STOPPED,
videoState: VideoState.STOPPED,
screenShareState: ScreenShareState.NOT_SHARING,
offeredFileId: null,
isTyping: false,
},
])
verificationToken: getUuid(),
encryptedVerificationToken: new ArrayBuffer(0),
verificationState: PeerVerificationState.VERIFYING,
verificationTimer: null,
}
sendTypingStatusChange({ isTyping }, peerId)
} else {
const oldUsername =
peerList[peerIndex].customUsername || getPeerName(userId)
const newUsername = customUsername || getPeerName(userId)
setPeerList([...peerList, newPeer])
sendTypingStatusChange({ isTyping }, peerId)
verifyPeer(newPeer)
} else {
const oldUsername =
peerList[peerIndex].customUsername || getPeerName(userId)
const newUsername = customUsername || getPeerName(userId)
const newPeerList = [...peerList]
const newPeer = { ...newPeerList[peerIndex], userId, customUsername }
newPeerList[peerIndex] = newPeer
setPeerList(newPeerList)
const newPeerList = [...peerList]
const newPeer = { ...newPeerList[peerIndex], userId, customUsername }
newPeerList[peerIndex] = newPeer
setPeerList(newPeerList)
if (oldUsername !== newUsername) {
showAlert(`${oldUsername} is now ${newUsername}`)
if (oldUsername !== newUsername) {
showAlert(`${oldUsername} is now ${newUsername}`)
}
}
}
})
)
receiveMessageTranscript(transcript => {
if (messageLog.length) return
@ -303,8 +339,12 @@ export function useRoom(
})
;(async () => {
try {
const publicKeyString = await encryptionService.stringifyCryptoKey(
publicKey
)
const promises: Promise<any>[] = [
sendPeerMetadata({ userId, customUsername }, peerId),
sendPeerMetadata({ userId, customUsername, publicKeyString }, peerId),
]
if (!isPrivate) {
@ -408,8 +448,18 @@ export function useRoom(
})
useEffect(() => {
sendPeerMetadata({ customUsername, userId })
}, [customUsername, userId, sendPeerMetadata])
;(async () => {
const publicKeyString = await encryptionService.stringifyCryptoKey(
publicKey
)
sendPeerMetadata({
customUsername,
userId,
publicKeyString,
})
})()
}, [customUsername, userId, sendPeerMetadata, publicKey, encryptionService])
useEffect(() => {
;(async () => {

View File

@ -8,10 +8,10 @@ import ListItem from '@mui/material/ListItem'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import { Username } from 'components/Username/Username'
import { UserInfo } from 'components/UserInfo'
import { AudioState, Peer } from 'models/chat'
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
import { TrackerConnection } from 'services/ConnectionTest/ConnectionTest'
import { PeerConnectionType } from 'services/PeerRoom'
import { TrackerConnection } from 'services/ConnectionTest'
import { PeerListHeader } from './PeerListHeader'
import { PeerListItem } from './PeerListItem'
@ -55,7 +55,7 @@ export const PeerList = ({
</ListItemIcon>
)}
<ListItemText>
<Username userId={userId} />
<UserInfo userId={userId} />
</ListItemText>
</ListItem>
{peerList.map((peer: Peer) => (

View File

@ -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 SyncAltIcon from '@mui/icons-material/SyncAlt'
import NetworkPingIcon from '@mui/icons-material/NetworkPing'
import ListItem from '@mui/material/ListItem'
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 { 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 { PeerDownloadFileButton } from './PeerDownloadFileButton'
@ -17,60 +28,128 @@ interface PeerListItemProps {
peerConnectionTypes: Record<string, PeerConnectionType>
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 = ({
peer,
peerConnectionTypes,
peerAudios,
}: PeerListItemProps): JSX.Element => {
const [showPeerDialog, setShowPeerDialog] = useState(false)
const hasPeerConnection = peer.peerId in peerConnectionTypes
const isPeerConnectionDirect =
peerConnectionTypes[peer.peerId] === PeerConnectionType.DIRECT
const handleListItemClick = () => {
setShowPeerDialog(true)
}
const handleDialogClose = () => {
setShowPeerDialog(false)
}
return (
<ListItem key={peer.peerId} divider={true}>
<PeerDownloadFileButton peer={peer} />
<ListItemText>
{hasPeerConnection ? (
<Tooltip
title={
isPeerConnectionDirect ? (
<>
You are connected directly to{' '}
<PeerNameDisplay
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
>
{peer.userId}
</PeerNameDisplay>
</>
) : (
<>
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.
</>
)
}
<>
<ListItem
key={peer.peerId}
divider={true}
onClick={handleListItemClick}
sx={{ cursor: 'pointer' }}
>
<PeerDownloadFileButton peer={peer} />
<ListItemText
primaryTypographyProps={{
sx: { display: 'flex', alignContent: 'center' },
}}
>
{hasPeerConnection ? (
<Tooltip
title={
isPeerConnectionDirect ? (
<>
You are connected directly to{' '}
<PeerNameDisplay
sx={{ fontSize: 'inherit', fontWeight: 'inherit' }}
>
{peer.userId}
</PeerNameDisplay>
</>
) : (
<>
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' }}>
{isPeerConnectionDirect ? (
<SyncAltIcon color="success" />
) : (
<NetworkPingIcon color="warning" />
)}
</Box>
</Tooltip>
) : null}
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
{peer.peerId in peerAudios && (
<AudioVolume audioEl={peerAudios[peer.peerId]} />
)}
</ListItemText>
</ListItem>
{verificationStateDisplayMap[peer.verificationState]}
</Box>
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
{peer.peerId in peerAudios && (
<AudioVolume audioEl={peerAudios[peer.peerId]} />
)}
</ListItemText>
</ListItem>
<Dialog open={showPeerDialog} onClose={handleDialogClose}>
<DialogTitle sx={{ display: 'flex', alignItems: 'center' }}>
{verificationStateDisplayMap[peer.verificationState]}
<Box component="span" sx={{ ml: 1 }}>
<PeerNameDisplay sx={{ fontSize: 'inherit' }}>
{peer.userId}
</PeerNameDisplay>
</Box>
</DialogTitle>
<DialogContent>
<DialogContentText>Their public key:</DialogContentText>
<PublicKey publicKey={peer.publicKey} />
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose}>Close</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -14,7 +14,8 @@ import CloseIcon from '@mui/icons-material/Close'
import { AlertOptions } from 'models/shell'
import { useEffect, useState, SyntheticEvent } from 'react'
import { encodePassword, sleep } from 'utils'
import { sleep } from 'utils'
import { encryptionService } from 'services/Encryption'
export interface RoomShareDialogProps {
isOpen: boolean
@ -50,22 +51,30 @@ export function RoomShareDialog(props: RoomShareDialogProps) {
const url = window.location.href.split('#')[0]
const copyWithPass = async () => {
const encoded = await encodePassword(props.roomId, password)
const encoded = await encryptionService.encodePassword(
props.roomId,
password
)
if (encoded === props.password) {
const params = new URLSearchParams()
params.set('secret', props.password)
await props.copyToClipboard(
`${url}#${params}`,
'Private room URL with password copied to clipboard',
'warning'
)
handleClose()
} else {
setPassThrottled(true)
props.showAlert('Incorrect password entered. Please wait 2s to retry.', {
severity: 'error',
})
await sleep(2000)
setPassThrottled(false)
}
}
@ -78,11 +87,13 @@ export function RoomShareDialog(props: RoomShareDialogProps) {
: 'Current URL copied to clipboard',
'success'
)
handleClose()
}
const handleFormSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault()
if (!passThrottled) copyWithPass()
}

View File

@ -109,17 +109,19 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const updatePeer = useCallback(
(peerId: string, updatedProperties: Partial<Peer>) => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
const doesPeerExist = peerIndex !== -1
setPeerList(peerList => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
const doesPeerExist = peerIndex !== -1
if (!doesPeerExist) return
if (!doesPeerExist) return peerList
const peerListClone = [...peerList]
const peer = peerList[peerIndex]
peerListClone[peerIndex] = { ...peer, ...updatedProperties }
setPeerList(peerListClone)
const peerListClone = [...peerList]
const peer = peerList[peerIndex]
peerListClone[peerIndex] = { ...peer, ...updatedProperties }
return peerListClone
})
},
[peerList]
[]
)
const shellContextValue = useMemo(

View 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>
</>
)
}

View File

@ -0,0 +1 @@
export * from './UserInfo'

View File

@ -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>
)
}

View File

@ -1 +0,0 @@
export * from './Username'

View File

@ -1,2 +1,3 @@
export const messageCharacterSizeLimit = 10_000
export const messageTranscriptSizeLimit = 150
export const verificationTimeout = 10_000

View File

@ -1,6 +1,7 @@
import { createContext } from 'react'
import { ColorMode, UserSettings } from 'models/settings'
import { encryptionService } from 'services/Encryption'
export interface SettingsContextProps {
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
@ -16,5 +17,7 @@ export const SettingsContext = createContext<SettingsContextProps>({
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey: encryptionService.cryptoKeyStub,
privateKey: encryptionService.cryptoKeyStub,
}),
})

View File

@ -2,12 +2,12 @@ import './polyfills'
import ReactDOM from 'react-dom/client'
import 'typeface-roboto'
import 'index.sass'
import Bootstrap from 'Bootstrap'
import reportWebVitals from 'reportWebVitals'
import './index.sass'
import Init from './Init'
import reportWebVitals from './reportWebVitals'
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
// to log results (for example: reportWebVitals(console.log))

View File

@ -41,15 +41,26 @@ export enum ScreenShareState {
NOT_SHARING = 'NOT_SHARING',
}
export enum PeerVerificationState {
VERIFYING,
UNVERIFIED,
VERIFIED,
}
export interface Peer {
peerId: string
userId: string
publicKey: CryptoKey
customUsername: string
audioState: AudioState
videoState: VideoState
screenShareState: ScreenShareState
offeredFileId: string | null
isTyping: boolean
verificationToken: string
encryptedVerificationToken: ArrayBuffer
verificationState: PeerVerificationState
verificationTimer: NodeJS.Timeout | null
}
export const isMessageReceived = (

View File

@ -9,4 +9,6 @@ export enum PeerActions {
SCREEN_SHARE = 'SCREEN_SHARE',
FILE_OFFER = 'FILE_OFFER',
TYPING_STATUS_CHANGE = 'TYPNG_CHANGE',
VERIFICATION_TOKEN_ENCRYPTED = 'V_TKN_ENC',
VERIFICATION_TOKEN_RAW = 'V_TKN_RAW',
}

View File

@ -16,4 +16,6 @@ export interface UserSettings {
playSoundOnNewMessage: boolean
showNotificationOnNewMessage: boolean
showActiveTypingStatus: boolean
publicKey: CryptoKeyPair['publicKey']
privateKey: CryptoKeyPair['privateKey']
}

View File

@ -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.
##### 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
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.

View File

@ -4,8 +4,8 @@ import { useParams } from 'react-router-dom'
import { ShellContext } from 'contexts/ShellContext'
import { NotificationService } from 'services/Notification'
import { PasswordPrompt } from 'components/PasswordPrompt/PasswordPrompt'
import { encodePassword } from 'utils'
import { PasswordPrompt } from 'components/PasswordPrompt'
import { encryptionService } from 'services/Encryption'
interface PublicRoomProps {
userId: string
@ -30,7 +30,8 @@ export function PrivateRoom({ userId }: PublicRoomProps) {
}, [roomId, setTitle])
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'))

View File

@ -0,0 +1 @@
export * from './ConnectionTest'

View 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()

View File

@ -0,0 +1 @@
export * from './Encryption'

View 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()

View File

@ -0,0 +1 @@
export * from './Serialization'

View 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

View 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,
}
}

View File

@ -1,5 +1,6 @@
import { SettingsContextProps } from 'contexts/SettingsContext'
import { ColorMode, UserSettings } from 'models/settings'
import { encryptionService } from 'services/Encryption'
export const userSettingsContextStubFactory = (
userSettingsOverrides: Partial<UserSettings> = {}
@ -13,6 +14,8 @@ export const userSettingsContextStubFactory = (
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey: encryptionService.cryptoKeyStub,
privateKey: encryptionService.cryptoKeyStub,
...userSettingsOverrides,
}),
}

View 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,
}
}

View File

@ -14,10 +14,3 @@ export const isRecord = (variable: any): variable is Record<string, any> => {
export const isError = (e: any): e is 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))
}