From ebf64b5bcb4e6a6dc6b0f06d392b038cdcd6d45b Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sun, 23 Feb 2025 22:17:08 -0500 Subject: [PATCH] fix: admin key can be saved/restored inside of web ui. --- .../Config/{ => Security}/Security.tsx | 230 +++++++++++------- .../Config/Security/securityReducer.tsx | 37 +++ .../PageComponents/Config/Security/types.ts | 24 ++ src/components/UI/Button.tsx | 2 +- src/components/UI/Dialog.tsx | 2 +- src/pages/Config/DeviceConfig.tsx | 2 +- 6 files changed, 205 insertions(+), 92 deletions(-) rename src/components/PageComponents/Config/{ => Security}/Security.tsx (52%) create mode 100644 src/components/PageComponents/Config/Security/securityReducer.tsx create mode 100644 src/components/PageComponents/Config/Security/types.ts diff --git a/src/components/PageComponents/Config/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx similarity index 52% rename from src/components/PageComponents/Config/Security.tsx rename to src/components/PageComponents/Config/Security/Security.tsx index 7152b387..018a8b53 100644 --- a/src/components/PageComponents/Config/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -1,5 +1,6 @@ import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog"; import { DynamicForm } from "@app/components/Form/DynamicForm.tsx"; +import { useAppStore } from "@app/core/stores/appStore"; import { getX25519PrivateKey, getX25519PublicKey, @@ -9,34 +10,73 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/js"; import { fromByteArray, toByteArray } from "base64-js"; import { Eye, EyeOff } from "lucide-react"; -import { useState } from "react"; +import { useReducer } from "react"; +import { securityReducer } from "./securityReducer"; -export const Security = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = - useDevice(); +export const Security = () => { + const { config, setWorkingConfig, setDialogOpen } = useDevice(); + const { + hasErrors, + getErrorMessage, + hasFieldError, + addError, + removeError, + clearErrors, + } = useAppStore(); - const [privateKey, setPrivateKey] = useState( - fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), - ); - const [privateKeyVisible, setPrivateKeyVisible] = useState(false); - const [privateKeyBitCount, setPrivateKeyBitCount] = useState( - config.security?.privateKey.length ?? 32, - ); - const [privateKeyValidationText, setPrivateKeyValidationText] = - useState(); - const [publicKey, setPublicKey] = useState( - fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), - ); - const [adminKey, setAdminKey] = useState( - fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)), - ); - const [adminKeyValidationText, setAdminKeyValidationText] = - useState(); - const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = - useState(false); + const [state, dispatch] = useReducer(securityReducer, { + privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), + privateKeyVisible: false, + adminKeyVisible: false, + privateKeyBitCount: config.security?.privateKey.length ?? 32, + adminKeyBitCount: config.security?.adminKey[0].length ?? 32, + publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), + adminKey: fromByteArray(config.security?.adminKey[0]), + privateKeyDialogOpen: false, + }); + + const validateKey = ( + input: string, + count: number, + fieldName: "privateKey" | "adminKey", + ) => { + try { + removeError(fieldName); + + if (input === "") { + addError( + fieldName, + `${fieldName === "privateKey" ? "Private" : "Admin"} Key is required`, + ); + return; + } + + if (input.length % 4 !== 0) { + addError( + fieldName, + `${fieldName === "privateKey" ? "Private" : "Admin"} Key is required to 256 bit pre-shared key (PSK)`, + ); + return; + } + + const decoded = toByteArray(input); + if (decoded.length !== count) { + addError(fieldName, `Please enter a valid ${count * 8} bit PSK`); + return; + } + } catch (e) { + console.error(e); + addError( + fieldName, + `Invalid ${fieldName === "privateKey" ? "Private" : "Admin"} Key format`, + ); + } + }; const onSubmit = (data: SecurityValidation) => { - if (privateKeyValidationText || adminKeyValidationText) return; + if (hasErrors()) { + return; + } setWorkingConfig( new Protobuf.Config.Config({ @@ -44,82 +84,56 @@ export const Security = (): JSX.Element => { case: "security", value: { ...data, - adminKey: [toByteArray(adminKey)], - privateKey: toByteArray(privateKey), - publicKey: toByteArray(publicKey), + adminKey: [toByteArray(state.adminKey)], + privateKey: toByteArray(state.privateKey), + publicKey: toByteArray(state.publicKey), }, }, }), ); }; - const validateKey = ( - input: string, - count: number, - setValidationText: ( - value: React.SetStateAction, - ) => void, - ) => { - 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.`); - } - }; - - const privateKeyClickEvent = () => { - setPrivateKeyDialogOpen(true); - }; - - const pkiBackupClickEvent = () => { - setDialogOpen("pkiBackup", true); - }; - const pkiRegenerate = () => { + clearErrors(); const privateKey = getX25519PrivateKey(); const publicKey = getX25519PublicKey(privateKey); - setPrivateKey(fromByteArray(privateKey)); - setPublicKey(fromByteArray(publicKey)); + dispatch({ + type: "REGENERATE_PRIV_PUB_KEY", + payload: { + privateKey: fromByteArray(privateKey), + publicKey: fromByteArray(publicKey), + }, + }); + validateKey( fromByteArray(privateKey), - privateKeyBitCount, - setPrivateKeyValidationText, + state.privateKeyBitCount, + "privateKey", ); - - setPrivateKeyDialogOpen(false); }; const privateKeyInputChangeEvent = ( e: React.ChangeEvent, ) => { const privateKeyB64String = e.target.value; - setPrivateKey(privateKeyB64String); - validateKey( - privateKeyB64String, - privateKeyBitCount, - setPrivateKeyValidationText, - ); + dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String }); + validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey"); const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String)); - setPublicKey(fromByteArray(publicKey)); + dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) }); }; const adminKeyInputChangeEvent = (e: React.ChangeEvent) => { const psk = e.currentTarget?.value; - setAdminKey(psk); - validateKey(psk, privateKeyBitCount, setAdminKeyValidationText); + dispatch({ type: "SET_ADMIN_KEY", payload: psk }); + validateKey(psk, state.privateKeyBitCount, "adminKey"); }; const privateKeySelectChangeEvent = (e: string) => { const count = Number.parseInt(e); - setPrivateKeyBitCount(count); - validateKey(privateKey, count, setPrivateKeyValidationText); + dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count }); + validateKey(state.privateKey, count, "privateKey"); }; return ( @@ -130,9 +144,9 @@ export const Security = (): JSX.Element => { defaultValues={{ ...config.security, ...{ - adminKey: adminKey, - privateKey: privateKey, - publicKey: publicKey, + adminKey: state.adminKey, + privateKey: state.privateKey, + publicKey: state.publicKey, adminChannelEnabled: config.security?.adminChannelEnabled ?? false, isManaged: config.security?.isManaged ?? false, debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, @@ -150,28 +164,35 @@ export const Security = (): JSX.Element => { 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, + validationText: hasFieldError("privateKey") + ? getErrorMessage("privateKey") + : "", + devicePSKBitCount: state.privateKeyBitCount, inputChange: privateKeyInputChangeEvent, selectChange: privateKeySelectChangeEvent, - hide: !privateKeyVisible, + hide: !state.privateKeyVisible, actionButtons: [ { text: "Generate", - onClick: privateKeyClickEvent, + onClick: () => + dispatch({ + type: "SHOW_PRIVATE_KEY_DIALOG", + payload: true, + }), variant: "success", }, { text: "Backup Key", - onClick: pkiBackupClickEvent, + onClick: () => setDialogOpen("pkiBackup", true), variant: "subtle", }, ], properties: { - value: privateKey, + value: state.privateKey, action: { - icon: privateKeyVisible ? EyeOff : Eye, - onClick: () => setPrivateKeyVisible(!privateKeyVisible), + icon: state.privateKeyVisible ? EyeOff : Eye, + onClick: () => + dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }), }, }, }, @@ -183,7 +204,7 @@ export const Security = (): JSX.Element => { description: "Sent out to other nodes on the mesh to allow them to compute a shared secret key", properties: { - value: publicKey, + value: state.publicKey, }, }, ], @@ -207,18 +228,47 @@ export const Security = (): JSX.Element => { "If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.", }, { - type: "text", + type: "passwordGenerator", name: "adminKey", label: "Admin Key", description: "The public key authorized to send admin messages to this node", - validationText: adminKeyValidationText, + validationText: hasFieldError("adminKey") + ? getErrorMessage("adminKey") + : "", inputChange: adminKeyInputChangeEvent, + selectChange: () => {}, + bits: [{ text: "256 bit", value: "32", key: "bit256" }], + devicePSKBitCount: state.privateKeyBitCount, + hide: !state.adminKeyVisible, + actionButtons: [ + { + text: "Generate", + variant: "success", + onClick: () => { + const adminKey = getX25519PrivateKey(); + dispatch({ + type: "REGENERATE_ADMIN_KEY", + payload: { adminKey: fromByteArray(adminKey) }, + }); + validateKey( + fromByteArray(adminKey), + state.adminKeyBitCount, + "adminKey", + ); + }, + }, + ], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: adminKey, + value: state.adminKey, + action: { + icon: state.adminKeyVisible ? EyeOff : Eye, + onClick: () => + dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }), + }, }, }, ], @@ -245,9 +295,11 @@ export const Security = (): JSX.Element => { ]} /> setPrivateKeyDialogOpen(false)} - onSubmit={() => pkiRegenerate()} + open={state.privateKeyDialogOpen} + onOpenChange={() => + dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false }) + } + onSubmit={pkiRegenerate} /> ); diff --git a/src/components/PageComponents/Config/Security/securityReducer.tsx b/src/components/PageComponents/Config/Security/securityReducer.tsx new file mode 100644 index 00000000..5795792f --- /dev/null +++ b/src/components/PageComponents/Config/Security/securityReducer.tsx @@ -0,0 +1,37 @@ +import type { SecurityAction, SecurityState } from "./types"; + +export function securityReducer( + state: SecurityState, + action: SecurityAction, +): SecurityState { + switch (action.type) { + case "SET_PRIVATE_KEY": + return { ...state, privateKey: action.payload }; + case "TOGGLE_PRIVATE_KEY_VISIBILITY": + return { ...state, privateKeyVisible: !state.privateKeyVisible }; + case "TOGGLE_ADMIN_KEY_VISIBILITY": + return { ...state, adminKeyVisible: !state.adminKeyVisible }; + case "SET_PRIVATE_KEY_BIT_COUNT": + return { ...state, privateKeyBitCount: action.payload }; + case "SET_PUBLIC_KEY": + return { ...state, publicKey: action.payload }; + case "SET_ADMIN_KEY": + return { ...state, adminKey: action.payload }; + case "SHOW_PRIVATE_KEY_DIALOG": + return { ...state, privateKeyDialogOpen: action.payload }; + case "REGENERATE_PRIV_PUB_KEY": + return { + ...state, + privateKey: action.payload.privateKey, + publicKey: action.payload.publicKey, + privateKeyDialogOpen: false, + }; + case "REGENERATE_ADMIN_KEY": + return { + ...state, + adminKey: action.payload.adminKey, + }; + default: + return state; + } +} diff --git a/src/components/PageComponents/Config/Security/types.ts b/src/components/PageComponents/Config/Security/types.ts new file mode 100644 index 00000000..bbebe10d --- /dev/null +++ b/src/components/PageComponents/Config/Security/types.ts @@ -0,0 +1,24 @@ +export interface SecurityState { + privateKey: string; + privateKeyVisible: boolean; + adminKeyVisible: boolean; + privateKeyBitCount: number; + adminKeyBitCount: number; + publicKey: string; + adminKey: string; + privateKeyDialogOpen: boolean; +} + +export type SecurityAction = + | { type: "SET_PRIVATE_KEY"; payload: string } + | { type: "TOGGLE_PRIVATE_KEY_VISIBILITY" } + | { type: "TOGGLE_ADMIN_KEY_VISIBILITY" } + | { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } + | { type: "SET_PUBLIC_KEY"; payload: string } + | { type: "SET_ADMIN_KEY"; payload: string } + | { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } + | { + type: "REGENERATE_PRIV_PUB_KEY"; + payload: { privateKey: string; publicKey: string }; + } + | { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } }; diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index a00d215b..d13d47f6 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -17,7 +17,7 @@ const buttonVariants = cva( outline: "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500", subtle: - "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 dark:bg-slate-700 dark:text-slate-100", + "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400", ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent", link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent", diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 5ba1176f..9f9578f5 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -105,7 +105,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index eb70ab33..1eb68a8e 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/src/pages/Config/DeviceConfig.tsx @@ -5,7 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.tsx"; import { Network } from "@components/PageComponents/Config/Network.tsx"; import { Position } from "@components/PageComponents/Config/Position.tsx"; import { Power } from "@components/PageComponents/Config/Power.tsx"; -import { Security } from "@components/PageComponents/Config/Security.tsx"; +import { Security } from "@components/PageComponents/Config/Security/Security"; import { Tabs, TabsContent,