diff --git a/README.md b/README.md index dd0f398..0101716 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Bootstrap.test.tsx b/src/Bootstrap.test.tsx index d3a87a9..6e0880e 100644 --- a/src/Bootstrap.test.tsx +++ b/src/Bootstrap.test.tsx @@ -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>('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 = {}) => { Object.assign(mockPersistedStorage, { getItem: mockGetItem, setItem: mockSetItem, @@ -27,6 +33,8 @@ const renderBootstrap = async (overrides: BootstrapProps = {}) => { render( ) @@ -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() -}) diff --git a/src/Bootstrap.tsx b/src/Bootstrap.tsx index 6de7885..73bd76d 100644 --- a/src/Bootstrap.tsx +++ b/src/Bootstrap.tsx @@ -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({ - userId: getUuid(), - customUsername: '', - colorMode: ColorMode.DARK, - playSoundOnNewMessage: true, - showNotificationOnNewMessage: true, - showActiveTypingStatus: true, - }) + const [userSettings, setUserSettings] = + useState(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( + const serializedUserSettings = { + // NOTE: This migrates persisted user settings data to latest version + ...(await serializationService.serializeUserSettings( + initialUserSettings + )), + ...(await persistedStorageProp.getItem( PersistedStorageKeys.USER_SETTINGS + )), + } + + const persistedUserSettings = + await serializationService.deserializeUserSettings( + serializedUserSettings ) const computeUserSettings = async (): Promise => { @@ -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({ ) : ( - <> + )} ) } - -export default Bootstrap diff --git a/src/Init.tsx b/src/Init.tsx new file mode 100644 index 0000000..c8433d7 --- /dev/null +++ b/src/Init.tsx @@ -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 { + 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(null) + const [errorMessage, setErrorMessage] = useState(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 + } + + if (errorMessage) { + return ( + + {errorMessage} + + ) + } + + if (userSettings === null) { + return + } + + return +} + +export default Init diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx new file mode 100644 index 0000000..3f44626 --- /dev/null +++ b/src/components/Loading/Loading.tsx @@ -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 ( + + + + ) +} diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts new file mode 100644 index 0000000..8e9305d --- /dev/null +++ b/src/components/Loading/index.ts @@ -0,0 +1 @@ +export * from './Loading' diff --git a/src/components/PublicKey/PublicKey.tsx b/src/components/PublicKey/PublicKey.tsx new file mode 100644 index 0000000..318ee6e --- /dev/null +++ b/src/components/PublicKey/PublicKey.tsx @@ -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 ( + + + {publicKeyString} + + + ) +} diff --git a/src/components/PublicKey/index.ts b/src/components/PublicKey/index.ts new file mode 100644 index 0000000..b059d44 --- /dev/null +++ b/src/components/PublicKey/index.ts @@ -0,0 +1 @@ +export * from './PublicKey' diff --git a/src/components/Room/Room.test.tsx b/src/components/Room/Room.test.tsx index 728a974..f011305 100644 --- a/src/components/Room/Room.test.tsx +++ b/src/components/Room/Room.test.tsx @@ -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 +} + describe('Room', () => { test('is available', () => { render( - + ) }) @@ -66,7 +71,7 @@ describe('Room', () => { test('send button is disabled', () => { render( - + ) @@ -77,7 +82,7 @@ describe('Room', () => { test('inputting text enabled send button', async () => { render( - + ) @@ -94,7 +99,7 @@ describe('Room', () => { test('sending a message clears the text input', async () => { render( - + ) @@ -115,7 +120,7 @@ describe('Room', () => { test('message is sent to peer', async () => { render( - 'abc123')} userId={mockUserId} roomId={mockRoomId} diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index 23a6986..ee46ff1 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -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, } ) diff --git a/src/components/Room/usePeerVerification.ts b/src/components/Room/usePeerVerification.ts new file mode 100644 index 0000000..4b3e1e3 --- /dev/null +++ b/src/components/Room/usePeerVerification.ts @@ -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(PeerActions.VERIFICATION_TOKEN_ENCRYPTED) + + const [sendVerificationTokenRaw, receiveVerificationTokenRaw] = + peerRoom.makeAction(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(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 } +} diff --git a/src/components/Room/useRoom.ts b/src/components/Room/useRoom.ts index ebca632..7ce20a2 100644 --- a/src/components/Room/useRoom.ts +++ b/src/components/Room/useRoom.ts @@ -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(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[] = [ - 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 () => { diff --git a/src/components/Shell/PeerList.tsx b/src/components/Shell/PeerList.tsx index 7f50722..d663a24 100644 --- a/src/components/Shell/PeerList.tsx +++ b/src/components/Shell/PeerList.tsx @@ -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 = ({ )} - + {peerList.map((peer: Peer) => ( diff --git a/src/components/Shell/PeerListItem.tsx b/src/components/Shell/PeerListItem.tsx index ac49753..7117875 100644 --- a/src/components/Shell/PeerListItem.tsx +++ b/src/components/Shell/PeerListItem.tsx @@ -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 peerAudios: Record } + +const verificationStateDisplayMap = { + [PeerVerificationState.UNVERIFIED]: ( + + + + ), + [PeerVerificationState.VERIFIED]: ( + + + + ), + [PeerVerificationState.VERIFYING]: ( + + + + ), +} + +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 ( - - - - {hasPeerConnection ? ( - - You are connected directly to{' '} - - {peer.userId} - - - ) : ( - <> - You are connected to{' '} - - {peer.userId} - {' '} - via a relay server. Your connection is still private and - encrypted, but performance may be degraded. - - ) - } + <> + + + + {hasPeerConnection ? ( + + You are connected directly to{' '} + + {peer.userId} + + + ) : ( + <> + You are connected to{' '} + + {peer.userId} + {' '} + via a relay server. Your connection is still private and + encrypted, but performance may be degraded. + + ) + } + > + + {isPeerConnectionDirect ? ( + + ) : ( + + )} + + + ) : null} + - - {isPeerConnectionDirect ? ( - - ) : ( - - )} - - - ) : null} - {peer.userId} - {peer.peerId in peerAudios && ( - - )} - - + {verificationStateDisplayMap[peer.verificationState]} + + {peer.userId} + {peer.peerId in peerAudios && ( + + )} + + + + + {verificationStateDisplayMap[peer.verificationState]} + + + {peer.userId} + + + + + Their public key: + + + + + + + ) } diff --git a/src/components/Shell/RoomShareDialog.tsx b/src/components/Shell/RoomShareDialog.tsx index b2e326d..fa9166d 100644 --- a/src/components/Shell/RoomShareDialog.tsx +++ b/src/components/Shell/RoomShareDialog.tsx @@ -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) => { event.preventDefault() + if (!passThrottled) copyWithPass() } diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index f8bd113..4df37df 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -109,17 +109,19 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const updatePeer = useCallback( (peerId: string, updatedProperties: Partial) => { - 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( diff --git a/src/components/UserInfo/UserInfo.tsx b/src/components/UserInfo/UserInfo.tsx new file mode 100644 index 0000000..1efb78f --- /dev/null +++ b/src/components/UserInfo/UserInfo.tsx @@ -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) => { + 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) => { + evt.preventDefault() + updateCustomUsername() + } + + const handleBlur = () => { + updateCustomUsername() + } + + const handleInfoButtonClick = () => { + setIsInfoDialogOpen(true) + } + + const handleInfoDialogClose = () => { + setIsInfoDialogOpen(false) + } + + return ( + <> +
+ + + + + + + + + + Your username + +
+ + + + + {userId} + + + + + + Your public key (generated locally): + + + + Your private key, which was also generated locally, is hidden and + only exists on your device. + + + + + + + + ) +} diff --git a/src/components/UserInfo/index.ts b/src/components/UserInfo/index.ts new file mode 100644 index 0000000..6e4af5a --- /dev/null +++ b/src/components/UserInfo/index.ts @@ -0,0 +1 @@ +export * from './UserInfo' diff --git a/src/components/Username/Username.tsx b/src/components/Username/Username.tsx deleted file mode 100644 index 5e8d551..0000000 --- a/src/components/Username/Username.tsx +++ /dev/null @@ -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) => { - 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) => { - evt.preventDefault() - updateCustomUsername() - } - - const handleBlur = () => { - updateCustomUsername() - } - - return ( -
- - - Your username - -
- ) -} diff --git a/src/components/Username/index.ts b/src/components/Username/index.ts deleted file mode 100644 index f2da48a..0000000 --- a/src/components/Username/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Username' diff --git a/src/config/messaging.ts b/src/config/messaging.ts index 6a34ed7..bb86d59 100644 --- a/src/config/messaging.ts +++ b/src/config/messaging.ts @@ -1,2 +1,3 @@ export const messageCharacterSizeLimit = 10_000 export const messageTranscriptSizeLimit = 150 +export const verificationTimeout = 10_000 diff --git a/src/contexts/SettingsContext.ts b/src/contexts/SettingsContext.ts index e32c001..2db8307 100644 --- a/src/contexts/SettingsContext.ts +++ b/src/contexts/SettingsContext.ts @@ -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) => Promise @@ -16,5 +17,7 @@ export const SettingsContext = createContext({ playSoundOnNewMessage: true, showNotificationOnNewMessage: true, showActiveTypingStatus: true, + publicKey: encryptionService.cryptoKeyStub, + privateKey: encryptionService.cryptoKeyStub, }), }) diff --git a/src/index.tsx b/src/index.tsx index c9fca47..d595c93 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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() +root.render() // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/src/models/chat.ts b/src/models/chat.ts index 6653638..530580e 100644 --- a/src/models/chat.ts +++ b/src/models/chat.ts @@ -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 = ( diff --git a/src/models/network.ts b/src/models/network.ts index c2ccf28..0eff365 100644 --- a/src/models/network.ts +++ b/src/models/network.ts @@ -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', } diff --git a/src/models/settings.ts b/src/models/settings.ts index b915816..a742607 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -16,4 +16,6 @@ export interface UserSettings { playSoundOnNewMessage: boolean showNotificationOnNewMessage: boolean showActiveTypingStatus: boolean + publicKey: CryptoKeyPair['publicKey'] + privateKey: CryptoKeyPair['privateKey'] } diff --git a/src/pages/About/About.tsx b/src/pages/About/About.tsx index 6052656..6b1e1bb 100644 --- a/src/pages/About/About.tsx +++ b/src/pages/About/About.tsx @@ -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. diff --git a/src/pages/PrivateRoom/PrivateRoom.tsx b/src/pages/PrivateRoom/PrivateRoom.tsx index 1fc3cc9..275b3ba 100644 --- a/src/pages/PrivateRoom/PrivateRoom.tsx +++ b/src/pages/PrivateRoom/PrivateRoom.tsx @@ -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')) diff --git a/src/services/ConnectionTest/index.ts b/src/services/ConnectionTest/index.ts new file mode 100644 index 0000000..c4dc92a --- /dev/null +++ b/src/services/ConnectionTest/index.ts @@ -0,0 +1 @@ +export * from './ConnectionTest' diff --git a/src/services/Encryption/Encryption.ts b/src/services/Encryption/Encryption.ts new file mode 100644 index 0000000..0a4e9be --- /dev/null +++ b/src/services/Encryption/Encryption.ts @@ -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 => { + 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() diff --git a/src/services/Encryption/index.ts b/src/services/Encryption/index.ts new file mode 100644 index 0000000..f515101 --- /dev/null +++ b/src/services/Encryption/index.ts @@ -0,0 +1 @@ +export * from './Encryption' diff --git a/src/services/Serialization/Serialization.ts b/src/services/Serialization/Serialization.ts new file mode 100644 index 0000000..4d1016a --- /dev/null +++ b/src/services/Serialization/Serialization.ts @@ -0,0 +1,61 @@ +import { UserSettings } from 'models/settings' +import { AllowedKeyType, encryptionService } from 'services/Encryption' + +export interface SerializedUserSettings + extends Omit { + publicKey: string + privateKey: string +} + +export class SerializationService { + serializeUserSettings = async ( + userSettings: UserSettings + ): Promise => { + 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 => { + 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() diff --git a/src/services/Serialization/index.ts b/src/services/Serialization/index.ts new file mode 100644 index 0000000..b647926 --- /dev/null +++ b/src/services/Serialization/index.ts @@ -0,0 +1 @@ +export * from './Serialization' diff --git a/src/test-utils/mocks/mockEncryptionService.ts b/src/test-utils/mocks/mockEncryptionService.ts new file mode 100644 index 0000000..a869b5e --- /dev/null +++ b/src/test-utils/mocks/mockEncryptionService.ts @@ -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 diff --git a/src/test-utils/mocks/mockSerializationService.ts b/src/test-utils/mocks/mockSerializationService.ts new file mode 100644 index 0000000..aae6354 --- /dev/null +++ b/src/test-utils/mocks/mockSerializationService.ts @@ -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, + } +} diff --git a/src/test-utils/stubs/settingsContext.ts b/src/test-utils/stubs/settingsContext.ts index cf8f5e8..d9615c1 100644 --- a/src/test-utils/stubs/settingsContext.ts +++ b/src/test-utils/stubs/settingsContext.ts @@ -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 = {} @@ -13,6 +14,8 @@ export const userSettingsContextStubFactory = ( playSoundOnNewMessage: true, showNotificationOnNewMessage: true, showActiveTypingStatus: true, + publicKey: encryptionService.cryptoKeyStub, + privateKey: encryptionService.cryptoKeyStub, ...userSettingsOverrides, }), } diff --git a/src/test-utils/stubs/userSettings.ts b/src/test-utils/stubs/userSettings.ts new file mode 100644 index 0000000..9b094f9 --- /dev/null +++ b/src/test-utils/stubs/userSettings.ts @@ -0,0 +1,18 @@ +import { ColorMode, UserSettings } from 'models/settings' +import { encryptionService } from 'services/Encryption' + +export const userSettingsStubFactory = ( + overrides: Partial = {} +): UserSettings => { + return { + userId: '1234-abcd', + customUsername: '', + colorMode: ColorMode.DARK, + playSoundOnNewMessage: true, + showNotificationOnNewMessage: true, + showActiveTypingStatus: true, + publicKey: encryptionService.cryptoKeyStub, + privateKey: encryptionService.cryptoKeyStub, + ...overrides, + } +} diff --git a/src/utils.ts b/src/utils.ts index d97832b..9c92d2f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,10 +14,3 @@ export const isRecord = (variable: any): variable is Record => { 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)) -}