Browse Source

Feature: Admin PKI fields (#619)

* Add admin PKI fields

* Renaming state ref

* WIP: Refactoring

* Refactoring Security Config

* Ensure admin keys have been set before allowing managed mode
pull/620/head
philon- 1 year ago
committed by GitHub
parent
commit
1274584497
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      src/components/Form/FormToggle.tsx
  2. 288
      src/components/PageComponents/Config/Security/Security.tsx
  3. 11
      src/components/PageComponents/Config/Security/securityReducer.tsx
  4. 27
      src/components/PageComponents/Config/Security/types.ts
  5. 7
      src/validation/config/security.ts

17
src/components/Form/FormToggle.tsx

@ -3,11 +3,11 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import type { ChangeEvent } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";
inputChange?: (value: boolean) => void;
}
export function ToggleInput<T extends FieldValues>({
@ -15,16 +15,6 @@ export function ToggleInput<T extends FieldValues>({
disabled,
field,
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
const onChangeHandler = (e: (event: ChangeEvent) => void) => {
return (value: boolean) => {
e({
target: {
value: value,
},
} as unknown as ChangeEvent);
};
};
return (
<Controller
name={field.name}
@ -32,7 +22,10 @@ export function ToggleInput<T extends FieldValues>({
render={({ field: { value, onChange, ...rest } }) => (
<Switch
checked={value}
onCheckedChange={onChangeHandler(onChange)}
onCheckedChange={(v) => {
onChange(v);
field.inputChange?.(v);
}}
id={field.name}
disabled={disabled}
{...field.properties}

288
src/components/PageComponents/Config/Security/Security.tsx

@ -9,6 +9,7 @@ import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
import type { SecurityConfigInit } from "./types.ts";
export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice();
@ -24,36 +25,53 @@ export const Security = () => {
const [state, dispatch] = useReducer(securityReducer, {
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
privateKeyVisible: false,
adminKeyVisible: false,
adminKeyVisible: [false, false, false],
privateKeyBitCount: config.security?.privateKey?.length ?? 32,
adminKeyBitCount: config.security?.adminKey?.at(0)?.length ?? 32,
publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
adminKey: fromByteArray(
config.security?.adminKey?.at(0) ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(config.security?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(config.security?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(config.security?.adminKey?.at(2) ?? new Uint8Array(0)),
],
privateKeyDialogOpen: false,
isManaged: config.security?.isManaged ?? false,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
});
const validateKey = (
input: string,
count: number,
fieldName: "privateKey" | "adminKey",
fieldIndex?: number,
) => {
const fieldNameKey = fieldName + (fieldIndex ?? "");
try {
removeError(fieldName);
removeError(fieldNameKey);
if (fieldName === "privateKey" && input === "") {
addError(fieldName, "Private Key is required");
addError(fieldNameKey, "Private Key is required");
return;
}
if (fieldName === "adminKey" && input === "") {
if (
state.isManaged && state.adminKey
.map((v, i) => i === fieldIndex ? input : v)
.every((s) => s === "")
) {
addError(
"adminKey0",
"At least one admin key is requred if the node is managed.",
);
}
return;
}
if (input.length % 4 !== 0) {
addError(
fieldName,
fieldNameKey,
`${
fieldName === "privateKey" ? "Private" : "Admin"
} Key is required to be a 256 bit pre-shared key (PSK)`,
@ -63,13 +81,13 @@ export const Security = () => {
const decoded = toByteArray(input);
if (decoded.length !== count) {
addError(fieldName, `Please enter a valid ${count * 8} bit PSK`);
addError(fieldNameKey, `Please enter a valid ${count * 8} bit PSK`);
return;
}
} catch (e) {
console.error(e);
addError(
fieldName,
fieldNameKey,
`Invalid ${
fieldName === "privateKey" ? "Private" : "Admin"
} Key format`,
@ -77,24 +95,32 @@ export const Security = () => {
}
};
const onSubmit = (data: SecurityValidation) => {
if (hasErrors()) {
return;
}
function setSecurityPayload(
overrides: SecurityConfigInit,
) {
const base: SecurityConfigInit = {
isManaged: state.isManaged,
adminChannelEnabled: state.adminChannelEnabled,
debugLogApiEnabled: state.debugLogApiEnabled,
serialEnabled: state.serialEnabled,
privateKey: overrides?.privateKey ?? toByteArray(state.privateKey),
publicKey: overrides?.publicKey ?? toByteArray(state.publicKey),
adminKey: [
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
],
};
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [new Uint8Array(0)],
privateKey: toByteArray(state.privateKey),
publicKey: toByteArray(state.publicKey),
},
value: { ...base, ...overrides },
},
}),
);
};
}
const pkiRegenerate = () => {
clearErrors();
@ -114,6 +140,13 @@ export const Security = () => {
state.privateKeyBitCount,
"privateKey",
);
if (!hasErrors()) {
setSecurityPayload({
privateKey: privateKey,
publicKey: publicKey,
});
}
};
const privateKeyInputChangeEvent = (
@ -125,25 +158,86 @@ export const Security = () => {
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
if (!hasErrors()) {
setSecurityPayload({
privateKey: toByteArray(privateKeyB64String),
publicKey: publicKey,
});
}
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
dispatch({ type: "SET_ADMIN_KEY", payload: psk });
validateKey(psk, state.privateKeyBitCount, "adminKey");
const adminKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
fieldIndex?: number,
) => {
if (fieldIndex === undefined) return;
const psk = e.target.value;
const payload = [
fieldIndex === 0 ? psk : state.adminKey[0],
fieldIndex === 1 ? psk : state.adminKey[1],
fieldIndex === 2 ? psk : state.adminKey[2],
] satisfies [string, string, string];
dispatch({ type: "SET_ADMIN_KEY", payload: payload });
validateKey(psk, state.privateKeyBitCount, "adminKey", fieldIndex);
if (!hasErrors()) {
setSecurityPayload({
adminKey: payload.map(toByteArray) as [
Uint8Array,
Uint8Array,
Uint8Array,
],
});
}
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count });
validateKey(state.privateKey, count, "privateKey");
const onToggleChange = (
field:
| "isManaged"
| "adminChannelEnabled"
| "debugLogApiEnabled"
| "serialEnabled",
next: boolean,
) => {
dispatch({ type: "SET_TOGGLE", field, payload: next });
if (
field === "isManaged" && state.adminKey.every((s) => s === "")
) {
if (next) {
addError(
"adminKey0",
"At least one admin key is requred if the node is managed.",
);
} else {
removeError("adminKey0");
removeError("adminKey1");
removeError("adminKey2");
}
}
if (!hasErrors()) {
setSecurityPayload({
isManaged: field === "isManaged" ? next : state.isManaged,
adminChannelEnabled: field === "adminChannelEnabled"
? next
: state.adminChannelEnabled,
debugLogApiEnabled: field === "debugLogApiEnabled"
? next
: state.debugLogApiEnabled,
serialEnabled: field === "serialEnabled" ? next : state.serialEnabled,
});
}
};
return (
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
onSubmit={() => {}}
submitType="onSubmit"
defaultValues={{
...config.security,
...{
@ -173,7 +267,7 @@ export const Security = () => {
: "",
devicePSKBitCount: state.privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
selectChange: () => {},
hide: !state.privateKeyVisible,
actionButtons: [
{
@ -216,58 +310,107 @@ export const Security = () => {
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
type: "passwordGenerator",
name: "adminKey.0",
id: "adminKey0Input",
label: "Primary Admin Key",
description:
"Allow incoming device control over the insecure legacy admin channel",
"The primary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey0")
? getErrorMessage("adminKey0")
: "",
inputChange: (e) => adminKeyInputChangeEvent(e, 0),
selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[0],
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: state.adminKey[0],
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
type: "passwordGenerator",
name: "adminKey.1",
id: "adminKey1Input",
label: "Secondary Admin Key",
description:
"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.",
"The secondary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey1")
? getErrorMessage("adminKey1")
: "",
inputChange: (e) => adminKeyInputChangeEvent(e, 1),
selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[1],
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: state.adminKey[1],
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "passwordGenerator",
name: "adminKey",
id: "adminKeyInput",
label: "Admin Key",
name: "adminKey.2",
id: "adminKey2Input",
label: "Tertiary Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey")
? getErrorMessage("adminKey")
"The tertiary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey2")
? getErrorMessage("adminKey2")
: "",
inputChange: adminKeyInputChangeEvent,
inputChange: (e) => adminKeyInputChangeEvent(e, 2),
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",
);
},
},
],
hide: !state.adminKeyVisible[2],
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: state.adminKey,
value: state.adminKey[2],
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
"If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
inputChange: (e: boolean) => onToggleChange("isManaged", e),
properties: {
checked: state.isManaged,
},
disabled: (
(hasFieldError("adminKey0") ||
hasFieldError("adminKey1") ||
hasFieldError("adminKey2")) &&
!state.adminKey.every((s) => s === "")
),
},
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
inputChange: (e: boolean) =>
onToggleChange("adminChannelEnabled", e),
properties: {
checked: state.adminChannelEnabled,
},
},
],
@ -282,12 +425,21 @@ export const Security = () => {
label: "Enable Debug Log API",
description:
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
inputChange: (e: boolean) =>
onToggleChange("debugLogApiEnabled", e),
properties: {
checked: state.debugLogApiEnabled,
},
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
inputChange: (e: boolean) => onToggleChange("serialEnabled", e),
properties: {
checked: state.serialEnabled,
},
},
],
},

11
src/components/PageComponents/Config/Security/securityReducer.tsx

@ -7,10 +7,6 @@ export function securityReducer(
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":
@ -26,11 +22,8 @@ export function securityReducer(
publicKey: action.payload.publicKey,
privateKeyDialogOpen: false,
};
case "REGENERATE_ADMIN_KEY":
return {
...state,
adminKey: action.payload.adminKey,
};
case "SET_TOGGLE":
return { ...state, [action.field]: action.payload };
default:
return state;
}

27
src/components/PageComponents/Config/Security/types.ts

@ -1,24 +1,35 @@
import { type MessageInitShape } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
export interface SecurityState {
privateKey: string;
privateKeyVisible: boolean;
adminKeyVisible: boolean;
adminKeyVisible: [boolean, boolean, boolean];
privateKeyBitCount: number;
adminKeyBitCount: number;
publicKey: string;
adminKey: string;
adminKey: [string, string, string];
privateKeyDialogOpen: boolean;
isManaged: boolean;
adminChannelEnabled: boolean;
debugLogApiEnabled: boolean;
serialEnabled: 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: "SET_ADMIN_KEY"; payload: [string, string, string] }
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean }
| { type: "SET_TOGGLE"; payload: boolean; field: string }
| {
type: "REGENERATE_PRIV_PUB_KEY";
payload: { privateKey: string; publicKey: string };
}
| { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } };
};
export type SecurityConfigInit = Extract<
MessageInitShape<
typeof Protobuf.Config.ConfigSchema
>["payloadVariant"],
{ case: "security" }
>["value"];

7
src/validation/config/security.ts

@ -5,13 +5,16 @@ import { IsBoolean, IsString } from "class-validator";
export class SecurityValidation implements
Omit<
Protobuf.Config.Config_SecurityConfig,
keyof Message | "adminKey" | "privateKey" | "publicKey"
| keyof Message
| "adminKey"
| "privateKey"
| "publicKey"
> {
@IsBoolean()
adminChannelEnabled: boolean;
@IsString()
adminKey: string;
adminKey: [string, string, string];
@IsBoolean()
bluetoothLoggingEnabled: boolean;

Loading…
Cancel
Save