diff --git a/package.json b/package.json index 5ef708e7..ea5a4169 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@bufbuild/protobuf": "^1.10.0", "@emeraldpay/hashicon-react": "^0.5.2", "@meshtastic/js": "2.3.7-1", + "@noble/curves": "^1.5.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a5e82b6..8c520c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@meshtastic/js': specifier: 2.3.7-1 version: 2.3.7-1 + '@noble/curves': + specifier: ^1.5.0 + version: 1.5.0 '@radix-ui/react-accordion': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -584,6 +587,13 @@ packages: '@meshtastic/js@2.3.7-1': resolution: {integrity: sha512-pv+Xk6HkKrScCrQp31k5QOUYozabXn6NhXN7c7Cc9ysG94U1wGtfueRbEbFxXCHO3JshNz0CdE1FcSMnrLMjsQ==} + '@noble/curves@1.5.0': + resolution: {integrity: sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3485,6 +3495,12 @@ snapshots: transitivePeerDependencies: - buffer + '@noble/curves@1.5.0': + dependencies: + '@noble/hashes': 1.4.0 + + '@noble/hashes@1.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/components/Dialog/PkiRegenerateDialog.tsx b/src/components/Dialog/PkiRegenerateDialog.tsx new file mode 100644 index 00000000..3edc221a --- /dev/null +++ b/src/components/Dialog/PkiRegenerateDialog.tsx @@ -0,0 +1,39 @@ +import { Button } from "@components/UI/Button.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.js"; + +export interface PkiRegenerateDialogProps { + open: boolean; + onOpenChange: () => void; + onSubmit: () => void; +} + +export const PkiRegenerateDialog = ({ + open, + onOpenChange, + onSubmit, +}: PkiRegenerateDialogProps): JSX.Element => { + return ( + + + + Regenerate Key pair? + + Are you sure you want to regenerate key pair? + + + + + + + + ); +}; diff --git a/src/components/Form/FormInput.tsx b/src/components/Form/FormInput.tsx index fced1b00..13f77260 100644 --- a/src/components/Form/FormInput.tsx +++ b/src/components/Form/FormInput.tsx @@ -4,11 +4,14 @@ import type { } from "@components/Form/DynamicForm.js"; import { Input } from "@components/UI/Input.js"; import type { LucideIcon } from "lucide-react"; +import type { ChangeEventHandler } from "react"; import { Controller, type FieldValues } from "react-hook-form"; export interface InputFieldProps extends BaseFormBuilderProps { type: "text" | "number" | "password"; + inputChange?: ChangeEventHandler; properties?: { + value?: string; prefix?: string; suffix?: string; step?: number; @@ -33,13 +36,14 @@ export function GenericInput({ type={field.type} step={field.properties?.step} value={field.type === "number" ? Number.parseFloat(value) : value} - onChange={(e) => + onChange={(e) => { + if (field.inputChange) field.inputChange(e); onChange( field.type === "number" ? Number.parseFloat(e.target.value) : e.target.value, - ) - } + ); + }} {...field.properties} {...rest} disabled={disabled} diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index a94b0215..cf05f806 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -9,6 +9,7 @@ import { Controller, type FieldValues } from "react-hook-form"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; hide?: boolean; + bits?: { text: string; value: string; key: string }[]; devicePSKBitCount: number; inputChange: ChangeEventHandler; selectChange: (event: string) => void; @@ -28,6 +29,7 @@ export function PasswordGenerator({ { ); const [privateKeyVisible, setPrivateKeyVisible] = useState(false); const [privateKeyBitCount, setPrivateKeyBitCount] = useState( - config.security?.privateKey.length ?? 16, + config.security?.privateKey.length ?? 32, ); const [privateKeyValidationText, setPrivateKeyValidationText] = useState(); @@ -25,12 +29,9 @@ export const Security = (): JSX.Element => { const [adminKey, setAdminKey] = useState( 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 [dialogOpen, setDialogOpen] = useState(false); const onSubmit = (data: SecurityValidation) => { if (privateKeyValidationText || adminKeyValidationText) return; @@ -50,191 +51,195 @@ 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 = ( + const validateKey = ( input: string, count: number, setValidationText: ( value: React.SetStateAction, ) => void, ) => { - if (input.length % 4 !== 0 || toByteArray(input).length !== count) { + try { + if (input.length % 4 !== 0 || toByteArray(input).length !== count) { + setValidationText(`Please enter a valid ${count * 8} bit PSK.`); + } else { + setValidationText(undefined); + } + } catch (e) { + console.error(e); setValidationText(`Please enter a valid ${count * 8} bit PSK.`); - } else { - setValidationText(undefined); } }; + const privateKeyClickEvent = () => { + setDialogOpen(true); + }; + + const pkiRegenerate = () => { + const privateKey = getX25519PrivateKey(); + const publicKey = getX25519PublicKey(privateKey); + + setPrivateKey(fromByteArray(privateKey)); + setPublicKey(fromByteArray(publicKey)); + validateKey( + fromByteArray(privateKey), + privateKeyBitCount, + setPrivateKeyValidationText, + ); + + setDialogOpen(false); + }; + const privateKeyInputChangeEvent = ( e: React.ChangeEvent, ) => { - const psk = e.currentTarget?.value; - setPrivateKey(psk); - validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText); + const privateKeyB64String = e.target.value; + setPrivateKey(privateKeyB64String); + validateKey( + privateKeyB64String, + privateKeyBitCount, + setPrivateKeyValidationText, + ); + + const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String)); + setPublicKey(fromByteArray(publicKey)); }; const adminKeyInputChangeEvent = (e: React.ChangeEvent) => { const psk = e.currentTarget?.value; setAdminKey(psk); - validatePass(psk, privateKeyBitCount, setAdminKeyValidationText); + validateKey(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); + validateKey(privateKey, count, setPrivateKeyValidationText); }; return ( - - onSubmit={onSubmit} - defaultValues={{ - ...config.security, - ...{ - adminKey: adminKey, - privateKey: privateKey, - publicKey: publicKey, - }, - }} - fieldGroups={[ - { - label: "Security Settings", - description: "Settings for the Security configuration", - fields: [ - { - 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", - invert: true, + <> + + onSubmit={onSubmit} + submitType="onChange" + defaultValues={{ + ...config.security, + ...{ + adminKey: adminKey, + privateKey: privateKey, + publicKey: publicKey, + adminChannelEnabled: config.security?.adminChannelEnabled ?? false, + isManaged: config.security?.isManaged ?? false, + bluetoothLoggingEnabled: + config.security?.bluetoothLoggingEnabled ?? false, + debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, + serialEnabled: config.security?.serialEnabled ?? false, + }, + }} + fieldGroups={[ + { + label: "Security Settings", + description: "Settings for the Security configuration", + fields: [ + { + type: "passwordGenerator", + name: "privateKey", + label: "Private Key", + description: "Used to create a shared key with a remote device", + bits: [{ text: "256 bit", value: "32", key: "bit256" }], + validationText: privateKeyValidationText, + devicePSKBitCount: privateKeyBitCount, + inputChange: privateKeyInputChangeEvent, + selectChange: privateKeySelectChangeEvent, + hide: !privateKeyVisible, + buttonClick: privateKeyClickEvent, + properties: { + value: privateKey, + action: { + icon: privateKeyVisible ? EyeOff : Eye, + onClick: () => setPrivateKeyVisible(!privateKeyVisible), + }, }, - ], - properties: { - value: privateKey, - action: { - icon: privateKeyVisible ? EyeOff : Eye, - onClick: () => setPrivateKeyVisible(!privateKeyVisible), + }, + { + type: "text", + name: "publicKey", + label: "Public Key", + disabled: true, + description: + "Sent out to other nodes on the mesh to allow them to compute a shared secret key", + properties: { + value: publicKey, }, }, - }, - { - type: "text", - name: "publicKey", - label: "Public Key", - disabled: true, - description: - "Sent out to other nodes on the mesh to allow them to compute a shared secret key", - }, - ], - }, - { - label: "Admin Settings", - description: "Settings for Admin ", - fields: [ - { - type: "toggle", - name: "adminChannelEnabled", - label: "Allow Legacy Admin", - description: - "Allow incoming device control over the insecure legacy admin channel", - }, - { - type: "toggle", - name: "isManaged", - label: "Managed", - description: - 'If true, device is considered to be "managed" by a mesh administrator via admin messages', - }, - { - 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), + ], + }, + { + label: "Admin Settings", + description: "Settings for Admin", + fields: [ + { + type: "toggle", + name: "adminChannelEnabled", + label: "Allow Legacy Admin", + description: + "Allow incoming device control over the insecure legacy admin channel", + }, + { + type: "toggle", + name: "isManaged", + label: "Managed", + description: + 'If true, device is considered to be "managed" by a mesh administrator via admin messages', + }, + { + type: "text", + name: "adminKey", + label: "Admin Key", + description: + "The public key authorized to send admin messages to this node", + validationText: adminKeyValidationText, + inputChange: adminKeyInputChangeEvent, + disabledBy: [ + { fieldName: "adminChannelEnabled", invert: true }, + ], + properties: { + value: adminKey, }, }, - }, - ], - }, - { - label: "Logging Settings", - description: "Settings for Logging", - fields: [ - { - type: "toggle", - name: "bluetoothLoggingEnabled", - label: "Allow Bluetooth Logging", - description: "Enables device (serial style logs) over Bluetooth", - }, - { - type: "toggle", - name: "debugLogApiEnabled", - label: "Enable Debug Log API", - description: "Output live debug logging over serial", - }, - { - type: "toggle", - name: "serialEnabled", - label: "Serial Output Enabled", - description: "Serial Console over the Stream API", - }, - ], - }, - ]} - /> + ], + }, + { + label: "Logging Settings", + description: "Settings for Logging", + fields: [ + { + type: "toggle", + name: "bluetoothLoggingEnabled", + label: "Allow Bluetooth Logging", + description: + "Enables device (serial style logs) over Bluetooth", + }, + { + type: "toggle", + name: "debugLogApiEnabled", + label: "Enable Debug Log API", + description: "Output live debug logging over serial", + }, + { + type: "toggle", + name: "serialEnabled", + label: "Serial Output Enabled", + description: "Serial Console over the Stream API", + }, + ], + }, + ]} + /> + setDialogOpen(false)} + onSubmit={() => pkiRegenerate()} + /> + ); }; diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 0e8b1e0d..7d589be4 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { value: string; variant: "default" | "invalid"; buttonText?: string; + bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; inputChange: (event: React.ChangeEvent) => void; buttonClick: React.MouseEventHandler; @@ -35,6 +36,11 @@ const Generator = React.forwardRef( variant, value, buttonText, + bits = [ + { text: "256 bit", value: "32", key: "bit256" }, + { text: "128 bit", value: "16", key: "bit128" }, + { text: "8 bit", value: "1", key: "bit8" }, + ], selectChange, inputChange, buttonClick, @@ -44,6 +50,21 @@ const Generator = React.forwardRef( }, ref, ) => { + const inputRef = React.useRef(null); + + // Invokes onChange event on the input element when the value changes from the parent component + React.useEffect(() => { + if (!inputRef.current) return; + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + + if (!setValue) return; + inputRef.current.value = ""; + setValue.call(inputRef.current, value); + inputRef.current.dispatchEvent(new Event("input", { bubbles: true })); + }, [value]); return ( <> ( onChange={inputChange} action={action} disabled={disabled} + ref={inputRef} />