fix: admin key can be saved/restored inside of web ui.

This commit is contained in:
Dan Ditomaso
2025-02-23 22:17:08 -05:00
parent 1560d1e18c
commit ebf64b5bcb
6 changed files with 205 additions and 92 deletions

View File

@@ -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<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
useState<boolean>(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<string | undefined>,
) => 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<HTMLInputElement>,
) => {
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<HTMLInputElement>) => {
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 => {
]}
/>
<PkiRegenerateDialog
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
open={state.privateKeyDialogOpen}
onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })
}
onSubmit={pkiRegenerate}
/>
</>
);

View File

@@ -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;
}
}

View File

@@ -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 } };

View File

@@ -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",

View File

@@ -105,7 +105,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500", "dark:text-slate-400", className)}
className={cn("text-sm text-slate-700", className)}
{...props}
/>
));

View File

@@ -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,