[closes #75] Easier sharing of private rooms (#81)

* Add URL secret reading logic
* Add private url share dialog
* Salt password hash with roomId
* Don't allow incorrect password to be entered

Co-authored-by: Jeremy Kahn <jeremyckahn@gmail.com>
This commit is contained in:
Nasal Daemon 2023-01-08 20:37:30 +00:00 committed by GitHub
parent d5aa4d7f0b
commit fcec242194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 285 additions and 14 deletions

View File

@ -40,7 +40,7 @@ export function useRoom(
{ password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig,
{ roomId, userId, getUuid = uuid }: UseRoomConfig
) {
const isPublicRoom = !password
const isPrivate = password !== undefined
const [peerRoom] = useState(
() => new PeerRoom({ password: password ?? roomId, ...roomConfig }, roomId)
@ -54,8 +54,11 @@ export function useRoom(
setPeerList,
tabHasFocus,
showAlert,
setRoomId,
setPassword,
setIsPeerListOpen,
} = useContext(ShellContext)
const settingsContext = useContext(SettingsContext)
const [isMessageSending, setIsMessageSending] = useState(false)
const [messageLog, _setMessageLog] = useState<Array<Message | InlineMedia>>(
@ -105,6 +108,7 @@ export function useRoom(
const roomContextValue = useMemo(
() => ({
isPrivate,
isMessageSending,
selfVideoStream,
setSelfVideoStream,
@ -118,6 +122,7 @@ export function useRoom(
setPeerOfferedFileMetadata,
}),
[
isPrivate,
isMessageSending,
selfVideoStream,
setSelfVideoStream,
@ -139,6 +144,22 @@ export function useRoom(
}
}, [peerRoom, setIsPeerListOpen])
useEffect(() => {
setPassword(password)
return () => {
setPassword(undefined)
}
}, [password, setPassword])
useEffect(() => {
setRoomId(roomId)
return () => {
setRoomId(undefined)
}
}, [roomId, setRoomId])
useEffect(() => {
setDoShowPeers(true)
@ -239,7 +260,7 @@ export function useRoom(
try {
const promises: Promise<any>[] = [sendPeerId(userId, peerId)]
if (isPublicRoom) {
if (!isPrivate) {
promises.push(
sendMessageTranscript(messageLog.filter(isMessageReceived), peerId)
)
@ -322,6 +343,7 @@ export function useRoom(
})
return {
isPrivate,
handleInlineMediaUpload,
isMessageSending,
messageLog,

View File

@ -0,0 +1,190 @@
import {
Alert,
AlertColor,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
FormControlLabel,
TextField,
Tooltip,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { AlertOptions } from 'models/shell'
import { useEffect, useState, SyntheticEvent } from 'react'
import { encodePassword, sleep } from 'utils'
export interface RoomShareDialogProps {
isOpen: boolean
handleClose: () => void
roomId: string
password: string
showAlert: (message: string, options?: AlertOptions) => void
copyToClipboard: (
content: string,
alert: string,
severity: AlertColor
) => Promise<void>
}
export function RoomShareDialog(props: RoomShareDialogProps) {
const [isAdvanced, setIsAdvanced] = useState(false)
const [isUnderstood, setIsUnderstood] = useState(false)
const [password, setPassword] = useState('')
const [passThrottled, setPassThrottled] = useState(false)
const handleClose = () => {
props.handleClose()
setPassword('')
}
useEffect(() => {
if (!isAdvanced) setIsUnderstood(false)
}, [isAdvanced])
useEffect(() => {
if (!isUnderstood) setPassword('')
}, [isUnderstood])
const url = window.location.href.split('#')[0]
const copyWithPass = async () => {
const encoded = await 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)
}
}
const copyWithoutPass = async () => {
await props.copyToClipboard(
url,
isAdvanced
? 'Private room URL without password copied to clipboard'
: 'Current URL copied to clipboard',
'success'
)
handleClose()
}
const handleFormSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault()
if (!passThrottled) copyWithPass()
}
return (
<Dialog
open={props.isOpen}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<form onSubmit={handleFormSubmit}>
{isAdvanced && (
<DialogTitle id="alert-dialog-title">
Copy URL with password
<Button onClick={() => setIsAdvanced(false)}>Simple</Button>
<IconButton
aria-label="close"
onClick={handleClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
)}
{isAdvanced && (
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Copy URL to this private room containing an indecipherable hash of
the password. When using this URL, users will not need to enter
the password themselves.
</DialogContentText>
<Alert severity="error" sx={{ mb: 2 }}>
Be careful where and how this URL is shared. Anybody who obtains
it can enter the room. The sharing medium must be trusted, as well
as all potential recipients of the URL, just as if you were
sharing the password itself.
</Alert>
<Alert severity="warning">
By design, the password hash does not leave the web browser when
this URL is used to access the room. However, web browsers can
still independently record the full URL in the address history,
and may even store the history in the cloud if configured to do
so.
</Alert>
<FormControlLabel
label="I understand the risks"
control={
<Checkbox
checked={isUnderstood}
onChange={e => setIsUnderstood(e.target.checked)}
/>
}
/>
<TextField
autoFocus
margin="none"
id="password"
label="Password"
type="password"
fullWidth
variant="standard"
value={password}
disabled={!isUnderstood}
onChange={e => setPassword(e.target.value)}
/>
</DialogContent>
)}
<DialogActions>
{isAdvanced ? (
<Tooltip title="Copy room URL with password. No password entry required to access room.">
<span>
<Button
type="submit"
onClick={copyWithPass}
color="error"
disabled={
password.length === 0 || !isUnderstood || passThrottled
}
>
Copy URL with password
</Button>
</span>
</Tooltip>
) : (
<Button onClick={() => setIsAdvanced(true)} color="error">
Advanced
</Button>
)}
<Tooltip title="Copy room URL. Password required to access room.">
<Button onClick={copyWithoutPass} color="success" autoFocus>
Copy URL
</Button>
</Tooltip>
</DialogActions>
</form>
</Dialog>
)
}

View File

@ -25,6 +25,7 @@ import { NotificationArea } from './NotificationArea'
import { RouteContent } from './RouteContent'
import { PeerList } from './PeerList'
import { QRCodeDialog } from './QRCodeDialog'
import { RoomShareDialog } from './RoomShareDialog'
export interface ShellProps extends PropsWithChildren {
userPeerId: string
@ -36,11 +37,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [isAlertShowing, setIsAlertShowing] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [isQRCodeDialogOpen, setIsQRCodeDialogOpen] = useState(false)
const [isRoomShareDialogOpen, setIsRoomShareDialogOpen] = useState(false)
const [doShowPeers, setDoShowPeers] = useState(false)
const [alertSeverity, setAlertSeverity] = useState<AlertColor>('info')
const [title, setTitle] = useState('')
const [alertText, setAlertText] = useState('')
const [numberOfPeers, setNumberOfPeers] = useState(1)
const [roomId, setRoomId] = useState<string | undefined>(undefined)
const [password, setPassword] = useState<string | undefined>(undefined)
const [isPeerListOpen, setIsPeerListOpen] = useState(false)
const [peerList, setPeerList] = useState<Peer[]>([]) // except you
const [tabHasFocus, setTabHasFocus] = useState(true)
@ -67,6 +71,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
showAlert,
isPeerListOpen,
setIsQRCodeDialogOpen,
roomId,
setRoomId,
password,
setPassword,
setIsPeerListOpen,
peerList,
setPeerList,
@ -80,6 +88,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
[
isPeerListOpen,
setIsQRCodeDialogOpen,
roomId,
setRoomId,
password,
setPassword,
numberOfPeers,
peerList,
tabHasFocus,
@ -146,12 +158,21 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setIsPeerListOpen(!isPeerListOpen)
}
const handleLinkButtonClick = async () => {
await navigator.clipboard.writeText(window.location.href)
const copyToClipboard = async (
content: string,
alert: string,
severity: AlertColor = 'success'
) => {
await navigator.clipboard.writeText(content)
shellContextValue.showAlert(alert, { severity })
}
shellContextValue.showAlert('Current URL copied to clipboard', {
severity: 'success',
})
const handleLinkButtonClick = async () => {
if (roomId !== undefined && password !== undefined) {
setIsRoomShareDialogOpen(true)
} else {
copyToClipboard(window.location.href, 'Current URL copied to clipboard')
}
}
const handleDrawerClose = () => {
@ -178,6 +199,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setIsQRCodeDialogOpen(false)
}
const handleRoomShareDialogClose = () => {
setIsRoomShareDialogOpen(false)
}
return (
<ShellContext.Provider value={shellContextValue}>
<ThemeProvider theme={theme}>
@ -234,6 +259,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
isOpen={isQRCodeDialogOpen}
handleClose={handleQRCodeDialogClose}
/>
<RoomShareDialog
isOpen={isRoomShareDialogOpen}
handleClose={handleRoomShareDialogClose}
roomId={roomId ?? ''}
password={password ?? ''}
showAlert={showAlert}
copyToClipboard={copyToClipboard}
/>
</Box>
</ThemeProvider>
</ShellContext.Provider>

View File

@ -2,6 +2,7 @@ import { createContext, Dispatch, SetStateAction } from 'react'
import { FileOfferMetadata } from 'models/chat'
interface RoomContextProps {
isPrivate: boolean
isMessageSending: boolean
selfVideoStream: MediaStream | null
setSelfVideoStream: Dispatch<SetStateAction<MediaStream | null>>
@ -18,6 +19,7 @@ interface RoomContextProps {
}
export const RoomContext = createContext<RoomContextProps>({
isPrivate: false,
isMessageSending: false,
selfVideoStream: null,
setSelfVideoStream: () => {},

View File

@ -10,6 +10,10 @@ interface ShellContextProps {
setNumberOfPeers: Dispatch<SetStateAction<number>>
setTitle: Dispatch<SetStateAction<string>>
showAlert: (message: string, options?: AlertOptions) => void
roomId?: string
setRoomId: Dispatch<SetStateAction<string | undefined>>
password?: string
setPassword: Dispatch<SetStateAction<string | undefined>>
isPeerListOpen: boolean
setIsPeerListOpen: Dispatch<SetStateAction<boolean>>
peerList: Peer[]
@ -29,6 +33,10 @@ export const ShellContext = createContext<ShellContextProps>({
setNumberOfPeers: () => {},
setTitle: () => {},
showAlert: () => {},
roomId: undefined,
setRoomId: () => {},
password: undefined,
setPassword: () => {},
isPeerListOpen: false,
setIsPeerListOpen: () => {},
peerList: [],

View File

@ -5,6 +5,7 @@ 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'
interface PublicRoomProps {
userId: string
@ -13,7 +14,12 @@ interface PublicRoomProps {
export function PrivateRoom({ userId }: PublicRoomProps) {
const { roomId = '' } = useParams()
const { setTitle } = useContext(ShellContext)
const [password, setPassword] = useState('')
const urlParams = new URLSearchParams(window.location.hash.substring(1))
// Clear secret from address bar
if (window.location.hash.length > 0)
window.history.replaceState(window.history.state, '', '#')
const [secret, setSecret] = useState(urlParams.get('secret') ?? '')
useEffect(() => {
NotificationService.requestPermission()
@ -23,18 +29,21 @@ export function PrivateRoom({ userId }: PublicRoomProps) {
setTitle(`Room: ${roomId}`)
}, [roomId, setTitle])
const handlePasswordEntered = (password: string) => {
setPassword(password)
const handlePasswordEntered = async (password: string) => {
if (password.length !== 0) setSecret(await encodePassword(roomId, password))
}
const isPasswordEntered = password.length === 0
if (urlParams.has('pwd') && !urlParams.has('secret'))
handlePasswordEntered(urlParams.get('pwd') ?? '')
return isPasswordEntered ? (
const awaitingSecret = secret.length === 0
return awaitingSecret ? (
<PasswordPrompt
isOpen={isPasswordEntered}
isOpen={awaitingSecret}
onPasswordEntered={handlePasswordEntered}
/>
) : (
<Room userId={userId} roomId={roomId} password={password} />
<Room userId={userId} roomId={roomId} password={secret} />
)
}

View File

@ -14,3 +14,10 @@ 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))
}