Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions ui/packages/shared/helpers/getEntropy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,52 @@ function logPow(expBase: number, pow: number, logBase: number): number {
return total
}

function getSecureRandomInt(max: number): number {
const array = new Uint32Array(1)
crypto.getRandomValues(array)
return array[0] % max
}

/**
* generates a cryptographically secure random password with guaranteed character diversity
*
* @param length - desired password length, constrained to 4-128 characters (default: 16)
* @returns a random password containing at least one character from each category:
* lowercase letters, uppercase letters, digits, and special characters (!@$&*_-.)
*/
export function generatePassword(length: number = 16): string {
const minLength = 4
const maxLength = 128
const actualLength = Math.max(Math.min(length, maxLength), minLength)

const lowercase = 'abcdefghijklmnopqrstuvwxyz'
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const digits = '0123456789'
const special = '!@$&*_-.'
const allChars = lowercase + uppercase + digits + special

let password = ''
// ensure at least one character from each category
password += lowercase[getSecureRandomInt(lowercase.length)]
password += uppercase[getSecureRandomInt(uppercase.length)]
password += digits[getSecureRandomInt(digits.length)]
password += special[getSecureRandomInt(special.length)]

// fill the rest with random characters
for (let i = password.length; i < actualLength; i++) {
password += allChars[getSecureRandomInt(allChars.length)]
}

// shuffle the password to randomize positions (Fisher-Yates)
const shuffled = password.split('')
for (let i = shuffled.length - 1; i > 0; i--) {
const j = getSecureRandomInt(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}

return shuffled.join('')
}

export function validatePassword(password: string, minEntropy: number): string {
const entropy: number = getEntropy(password)
if (entropy >= minEntropy) {
Expand Down
64 changes: 59 additions & 5 deletions ui/packages/shared/pages/CreateClone/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import React, { useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { observer } from 'mobx-react-lite'
import { useTimer } from 'use-timer'
import { Paper, FormControlLabel, Checkbox } from '@material-ui/core'
import { Info as InfoIcon } from '@material-ui/icons'
import { Paper, FormControlLabel, Checkbox, IconButton, InputAdornment } from '@material-ui/core'
import { Info as InfoIcon, Visibility, VisibilityOff } from '@material-ui/icons'
import copy from 'copy-to-clipboard'

import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex'
import { TextField } from '@postgres.ai/shared/components/TextField'
import { Select } from '@postgres.ai/shared/components/Select'
import { Button } from '@postgres.ai/shared/components/Button'
import { Spinner } from '@postgres.ai/shared/components/Spinner'
import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub'
import { Tooltip } from '@postgres.ai/shared/components/Tooltip'
import { round } from '@postgres.ai/shared/utils/numbers'
import { formatBytesIEC } from '@postgres.ai/shared/utils/units'
import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle'
Expand All @@ -20,6 +22,7 @@ import {
MIN_ENTROPY,
getEntropy,
validatePassword,
generatePassword,
} from '@postgres.ai/shared/helpers/getEntropy'

import { Snapshot } from '@postgres.ai/shared/types/api/entities/snapshot'
Expand Down Expand Up @@ -53,6 +56,8 @@ export const CreateClone = observer((props: Props) => {
const [snapshots, setSnapshots] = useState([] as Snapshot[])
const [isLoadingSnapshots, setIsLoadingSnapshots] = useState(false)
const [selectedBranchKey, setSelectedBranchKey] = useState<string>('')
const [showPassword, setShowPassword] = useState(false)
const [passwordGenerated, setPasswordGenerated] = useState(false)

// Form.
const onSubmit = async (values: FormValues) => {
Expand Down Expand Up @@ -314,18 +319,65 @@ export const CreateClone = observer((props: Props) => {
<TextField
fullWidth
label="Database password *"
type="password"
type={showPassword ? 'text' : 'password'}
value={formik.values.dbPassword}
onChange={(e) => {
formik.setFieldValue('dbPassword', e.target.value)
setPasswordGenerated(false)

if (formik.errors.dbPassword) {
formik.setFieldError('dbPassword', '')
}
}}
error={Boolean(formik.errors.dbPassword)}
disabled={isCreatingClone}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip content={showPassword ? 'Hide password' : 'Show password'}>
<IconButton
size="small"
onClick={() => setShowPassword(!showPassword)}
disabled={isCreatingClone}
style={{ marginRight: 4 }}
>
{showPassword ? <Visibility fontSize="small" /> : <VisibilityOff fontSize="small" />}
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
<div className={styles.passwordActions}>
<Button
variant="secondary"
size="small"
onClick={() => copy(formik.values.dbPassword)}
isDisabled={isCreatingClone || !formik.values.dbPassword}
>
Copy
</Button>
<Button
variant="secondary"
size="small"
onClick={() => {
const newPassword = generatePassword(16)
formik.setFieldValue('dbPassword', newPassword)
setPasswordGenerated(true)
if (formik.errors.dbPassword) {
formik.setFieldError('dbPassword', '')
}
}}
isDisabled={isCreatingClone}
>
Generate
</Button>
{passwordGenerated && (
<span className={styles.passwordHint}>
New password created. Copy and save it securely.
</span>
)}
</div>
<p
className={cn(
formik.errors.dbPassword && styles.error,
Expand All @@ -336,7 +388,9 @@ export const CreateClone = observer((props: Props) => {
</p>
</div>

<div className={styles.form}>
<div className={styles.section}>
<h2 className={styles.title}>Clone protection</h2>

<FormControlLabel
label="Enable deletion protection"
control={
Expand Down Expand Up @@ -423,7 +477,7 @@ export const CreateClone = observer((props: Props) => {
</p>
<SyntaxHighlight
wrapLines
content={getCliCreateCloneCommand(formik.values)}
content={getCliCreateCloneCommand(formik.values, showPassword)}
/>

<SectionTitle
Expand Down
14 changes: 14 additions & 0 deletions ui/packages/shared/pages/CreateClone/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,18 @@
.pageTitle {
margin-top: 8px;
line-height: 26px;
}

.passwordActions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
margin-bottom: 16px;
}

.passwordHint {
font-size: 12px;
color: #f57c00;
font-weight: 500;
}
29 changes: 22 additions & 7 deletions ui/packages/shared/pages/CreateClone/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { FormValues } from '@postgres.ai/shared/pages/CreateClone/useForm'

export const getCliCreateCloneCommand = (values: FormValues) => {
// escape string for use in single-quoted shell argument
const shellEscape = (str: string): string => {
// replace single quotes with: end quote, escaped quote, start quote
return "'" + str.replace(/'/g, "'\\''") + "'"
}

export const getCliCreateCloneCommand = (values: FormValues, showPassword?: boolean) => {
const { dbUser, dbPassword, branch, isProtected, cloneId } = values

const usernameDisplay = dbUser ? shellEscape(dbUser) : `<USERNAME>`

const passwordDisplay = dbPassword
? (showPassword ? shellEscape(dbPassword) : dbPassword.replace(/./g, '*'))
: `<PASSWORD>`

const cloneIdDisplay = cloneId ? shellEscape(cloneId) : `<CLONE_ID>`

return `dblab clone create \

--username ${dbUser ? dbUser : `<USERNAME>`} \
--username ${usernameDisplay} \

--password ${dbPassword ? dbPassword.replace(/./g, '*') : `<PASSWORD>`} \
--password ${passwordDisplay} \

${branch ? `--branch ${branch}` : ``} \
${branch ? `--branch ${shellEscape(branch)}` : ``} \

${isProtected ? `--protected` : ''} \
${isProtected ? `--protected` : ''} \

--id ${cloneId ? cloneId : `<CLONE_ID>`} \ `
--id ${cloneIdDisplay} \ `
}

export const getCliCloneStatus = (cloneId: string) => {
return `dblab clone status ${cloneId ? cloneId : `<CLONE_ID>`}`
const cloneIdDisplay = cloneId ? shellEscape(cloneId) : `<CLONE_ID>`
return `dblab clone status ${cloneIdDisplay}`
}
Loading