diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index d015d3af..a94b0215 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -8,6 +8,7 @@ import { Controller, type FieldValues } from "react-hook-form"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; + hide?: boolean; devicePSKBitCount: number; inputChange: ChangeEventHandler; selectChange: (event: string) => void; @@ -17,6 +18,7 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { export function PasswordGenerator({ control, field, + disabled, }: GenericFormElementProps>) { return ( ({ control={control} render={({ field: { value, ...rest } }) => ( ({ buttonText="Generate" {...field.properties} {...rest} + disabled={disabled} /> )} /> diff --git a/src/components/PageComponents/Config/Security.tsx b/src/components/PageComponents/Config/Security.tsx index c9e1175c..a82ed980 100644 --- a/src/components/PageComponents/Config/Security.tsx +++ b/src/components/PageComponents/Config/Security.tsx @@ -3,6 +3,7 @@ import type { SecurityValidation } from "@app/validation/config/security.js"; import { useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/js"; import { fromByteArray, toByteArray } from "base64-js"; +import cryptoRandomString from "crypto-random-string"; import { Eye, EyeOff } from "lucide-react"; import { useState } from "react"; @@ -13,6 +14,11 @@ export const Security = (): JSX.Element => { fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), ); const [privateKeyVisible, setPrivateKeyVisible] = useState(false); + const [privateKeyBitCount, setPrivateKeyBitCount] = useState( + config.security?.privateKey.length ?? 16, + ); + const [privateKeyValidationText, setPrivateKeyValidationText] = + useState(); const [publicKey, setPublicKey] = useState( fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), ); @@ -20,8 +26,15 @@ export const Security = (): JSX.Element => { fromByteArray(config.security?.adminKey ?? new Uint8Array(0)), ); const [adminKeyVisible, setAdminKeyVisible] = useState(false); + const [adminKeyBitCount, setAdminKeyBitCount] = useState( + config.security?.adminKey.length ?? 16, + ); + const [adminKeyValidationText, setAdminKeyValidationText] = + useState(); const onSubmit = (data: SecurityValidation) => { + if (privateKeyValidationText || adminKeyValidationText) return; + setWorkingConfig( new Protobuf.Config.Config({ payloadVariant: { @@ -36,14 +49,75 @@ export const Security = (): JSX.Element => { }), ); }; + + const clickEvent = ( + setKey: (value: React.SetStateAction) => void, + bitCount: number, + setValidationText: ( + value: React.SetStateAction, + ) => void, + ) => { + setKey( + btoa( + cryptoRandomString({ + length: bitCount ?? 0, + type: "alphanumeric", + }), + ), + ); + setValidationText(undefined); + }; + + const validatePass = ( + input: string, + count: number, + setValidationText: ( + value: React.SetStateAction, + ) => void, + ) => { + if (input.length % 4 !== 0 || toByteArray(input).length !== count) { + setValidationText(`Please enter a valid ${count * 8} bit PSK.`); + } else { + setValidationText(undefined); + } + }; + + const privateKeyInputChangeEvent = ( + e: React.ChangeEvent, + ) => { + const psk = e.currentTarget?.value; + setPrivateKey(psk); + validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText); + }; + + const adminKeyInputChangeEvent = (e: React.ChangeEvent) => { + const psk = e.currentTarget?.value; + setAdminKey(psk); + validatePass(psk, privateKeyBitCount, setAdminKeyValidationText); + }; + + const privateKeySelectChangeEvent = (e: string) => { + const count = Number.parseInt(e); + setPrivateKeyBitCount(count); + validatePass(privateKey, count, setPrivateKeyValidationText); + }; + + const adminKeySelectChangeEvent = (e: string) => { + const count = Number.parseInt(e); + setAdminKeyBitCount(count); + validatePass(privateKey, count, setAdminKeyValidationText); + }; + return ( onSubmit={onSubmit} defaultValues={{ ...config.security, - adminKey: adminKey, - privateKey: privateKey, - publicKey: publicKey, + ...{ + adminKey: adminKey, + privateKey: privateKey, + publicKey: publicKey, + }, }} fieldGroups={[ { @@ -51,10 +125,21 @@ export const Security = (): JSX.Element => { description: "Settings for the Security configuration", fields: [ { - type: privateKeyVisible ? "text" : "password", + type: "passwordGenerator", name: "privateKey", label: "Private Key", description: "Used to create a shared key with a remote device", + validationText: privateKeyValidationText, + devicePSKBitCount: privateKeyBitCount, + inputChange: privateKeyInputChangeEvent, + selectChange: privateKeySelectChangeEvent, + hide: !privateKeyVisible, + buttonClick: () => + clickEvent( + setPrivateKey, + privateKeyBitCount, + setPrivateKeyValidationText, + ), disabledBy: [ { fieldName: "adminChannelEnabled", @@ -62,6 +147,7 @@ export const Security = (): JSX.Element => { }, ], properties: { + value: privateKey, action: { icon: privateKeyVisible ? EyeOff : Eye, onClick: () => setPrivateKeyVisible(!privateKeyVisible), @@ -97,18 +183,30 @@ export const Security = (): JSX.Element => { 'If true, device is considered to be "managed" by a mesh administrator via admin messages', }, { - type: adminKeyVisible ? "text" : "password", + type: "passwordGenerator", name: "adminKey", label: "Admin Key", + description: + "The public key authorized to send admin messages to this node", + validationText: adminKeyValidationText, + devicePSKBitCount: adminKeyBitCount, + inputChange: adminKeyInputChangeEvent, + selectChange: adminKeySelectChangeEvent, + hide: !adminKeyVisible, + buttonClick: () => + clickEvent( + setAdminKey, + adminKeyBitCount, + setAdminKeyValidationText, + ), disabledBy: [{ fieldName: "adminChannelEnabled" }], properties: { + value: adminKey, action: { icon: adminKeyVisible ? EyeOff : Eye, onClick: () => setAdminKeyVisible(!adminKeyVisible), }, }, - description: - "The public key authorized to send admin messages to this node", }, ], }, diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 344e89bb..0e8b1e0d 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -9,8 +9,10 @@ import { SelectTrigger, SelectValue, } from "@components/UI/Select.js"; +import type { LucideIcon } from "lucide-react"; export interface GeneratorProps extends React.BaseHTMLAttributes { + hide?: boolean; devicePSKBitCount?: number; value: string; variant: "default" | "invalid"; @@ -18,11 +20,17 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { selectChange: (event: string) => void; inputChange: (event: React.ChangeEvent) => void; buttonClick: React.MouseEventHandler; + action?: { + icon: LucideIcon; + onClick: () => void; + }; + disabled?: boolean; } const Generator = React.forwardRef( ( { + hide = true, devicePSKBitCount, variant, value, @@ -30,6 +38,8 @@ const Generator = React.forwardRef( selectChange, inputChange, buttonClick, + action, + disabled, ...props }, ref, @@ -37,17 +47,19 @@ const Generator = React.forwardRef( return ( <>