Browse Source

Merge branch 'pki' into pki-nodelist

pull/293/head
Hunter Thornsberry 2 years ago
committed by GitHub
parent
commit
87c729d694
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      package.json
  2. 16
      pnpm-lock.yaml
  3. 39
      src/components/Dialog/PkiRegenerateDialog.tsx
  4. 10
      src/components/Form/FormInput.tsx
  5. 2
      src/components/Form/FormPasswordGenerator.tsx
  6. 269
      src/components/PageComponents/Config/Security.tsx
  7. 38
      src/components/UI/Generator.tsx
  8. 3
      src/components/UI/Input.tsx
  9. 15
      src/core/utils/x25519.ts

1
package.json

@ -24,6 +24,7 @@
"@bufbuild/protobuf": "^1.10.0", "@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.7-1", "@meshtastic/js": "2.3.7-1",
"@noble/curves": "^1.5.0",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",

16
pnpm-lock.yaml

@ -17,6 +17,9 @@ importers:
'@meshtastic/js': '@meshtastic/js':
specifier: 2.3.7-1 specifier: 2.3.7-1
version: 2.3.7-1 version: 2.3.7-1
'@noble/curves':
specifier: ^1.5.0
version: 1.5.0
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.2.0(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
@ -587,6 +590,13 @@ packages:
'@meshtastic/[email protected]': '@meshtastic/[email protected]':
resolution: {integrity: sha512-pv+Xk6HkKrScCrQp31k5QOUYozabXn6NhXN7c7Cc9ysG94U1wGtfueRbEbFxXCHO3JshNz0CdE1FcSMnrLMjsQ==} resolution: {integrity: sha512-pv+Xk6HkKrScCrQp31k5QOUYozabXn6NhXN7c7Cc9ysG94U1wGtfueRbEbFxXCHO3JshNz0CdE1FcSMnrLMjsQ==}
'@noble/[email protected]':
resolution: {integrity: sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==}
'@noble/[email protected]':
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'}
'@nodelib/[email protected]': '@nodelib/[email protected]':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -3521,6 +3531,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- buffer - buffer
'@noble/[email protected]':
dependencies:
'@noble/hashes': 1.4.0
'@noble/[email protected]': {}
'@nodelib/[email protected]': '@nodelib/[email protected]':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5

39
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

10
src/components/Form/FormInput.tsx

@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: { properties?: {
value?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
step?: number; step?: number;
@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type} type={field.type}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value} value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) => onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange( onChange(
field.type === "number" field.type === "number"
? Number.parseFloat(e.target.value) ? Number.parseFloat(e.target.value)
: e.target.value, : e.target.value,
) );
} }}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}

2
src/components/Form/FormPasswordGenerator.tsx

@ -9,6 +9,7 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> { export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator"; type: "passwordGenerator";
hide?: boolean; hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number; devicePSKBitCount: number;
inputChange: ChangeEventHandler; inputChange: ChangeEventHandler;
selectChange: (event: string) => void; selectChange: (event: string) => void;
@ -28,6 +29,7 @@ export function PasswordGenerator<T extends FieldValues>({
<Generator <Generator
hide={field.hide} hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount} devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange} inputChange={field.inputChange}
selectChange={field.selectChange} selectChange={field.selectChange}
buttonClick={field.buttonClick} buttonClick={field.buttonClick}

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

@ -1,9 +1,13 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js"; import { DynamicForm } from "@app/components/Form/DynamicForm.js";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.js"; import type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@ -15,7 +19,7 @@ export const Security = (): JSX.Element => {
); );
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false); const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>( const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 16, config.security?.privateKey.length ?? 32,
); );
const [privateKeyValidationText, setPrivateKeyValidationText] = const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>(); useState<string>();
@ -25,12 +29,9 @@ export const Security = (): JSX.Element => {
const [adminKey, setAdminKey] = useState<string>( const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)), fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
); );
const [adminKeyVisible, setAdminKeyVisible] = useState<boolean>(false);
const [adminKeyBitCount, setAdminKeyBitCount] = useState<number>(
config.security?.adminKey.length ?? 16,
);
const [adminKeyValidationText, setAdminKeyValidationText] = const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>(); useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => { const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return; if (privateKeyValidationText || adminKeyValidationText) return;
@ -50,161 +51,159 @@ export const Security = (): JSX.Element => {
); );
}; };
const clickEvent = ( const validateKey = (
setKey: (value: React.SetStateAction<string>) => void,
bitCount: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
setKey(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
};
const validatePass = (
input: string, input: string,
count: number, count: number,
setValidationText: ( setValidationText: (
value: React.SetStateAction<string | undefined>, value: React.SetStateAction<string | undefined>,
) => void, ) => 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.`); 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 = ( const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
) => { ) => {
const psk = e.currentTarget?.value; const privateKeyB64String = e.target.value;
setPrivateKey(psk); setPrivateKey(privateKeyB64String);
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText); validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
}; };
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => { const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value; const psk = e.currentTarget?.value;
setAdminKey(psk); setAdminKey(psk);
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText); validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
}; };
const privateKeySelectChangeEvent = (e: string) => { const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e); const count = Number.parseInt(e);
setPrivateKeyBitCount(count); setPrivateKeyBitCount(count);
validatePass(privateKey, count, setPrivateKeyValidationText); validateKey(privateKey, count, setPrivateKeyValidationText);
};
const adminKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setAdminKeyBitCount(count);
validatePass(privateKey, count, setAdminKeyValidationText);
}; };
return ( return (
<DynamicForm<SecurityValidation> <>
onSubmit={onSubmit} <DynamicForm<SecurityValidation>
defaultValues={{ onSubmit={onSubmit}
...config.security, submitType="onChange"
...{ defaultValues={{
adminKey: adminKey, ...config.security,
privateKey: privateKey, ...{
publicKey: publicKey, adminKey: adminKey,
}, privateKey: privateKey,
}} publicKey: publicKey,
fieldGroups={[ adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
{ isManaged: config.security?.isManaged ?? false,
label: "Security Settings", bluetoothLoggingEnabled:
description: "Settings for the Security configuration", config.security?.bluetoothLoggingEnabled ?? false,
fields: [ debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
{ serialEnabled: config.security?.serialEnabled ?? false,
type: "passwordGenerator", },
name: "privateKey", }}
label: "Private Key", fieldGroups={[
description: "Used to create a shared key with a remote device", {
validationText: privateKeyValidationText, label: "Security Settings",
devicePSKBitCount: privateKeyBitCount, description: "Settings for the Security configuration",
inputChange: privateKeyInputChangeEvent, fields: [
selectChange: privateKeySelectChangeEvent, {
hide: !privateKeyVisible, type: "passwordGenerator",
buttonClick: () => name: "privateKey",
clickEvent( label: "Private Key",
setPrivateKey, description: "Used to create a shared key with a remote device",
privateKeyBitCount, bits: [{ text: "256 bit", value: "32", key: "bit256" }],
setPrivateKeyValidationText, validationText: privateKeyValidationText,
), devicePSKBitCount: privateKeyBitCount,
disabledBy: [ inputChange: privateKeyInputChangeEvent,
{ selectChange: privateKeySelectChangeEvent,
fieldName: "adminChannelEnabled", hide: !privateKeyVisible,
invert: true, buttonClick: privateKeyClickEvent,
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
}, },
], },
properties: { {
value: privateKey, type: "text",
action: { name: "publicKey",
icon: privateKeyVisible ? EyeOff : Eye, label: "Public Key",
onClick: () => setPrivateKeyVisible(!privateKeyVisible), 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: "Admin Settings",
label: "Public Key", description: "Settings for Admin",
disabled: true, fields: [
description: {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key", type: "toggle",
}, name: "adminChannelEnabled",
], label: "Allow Legacy Admin",
}, description:
{ "Allow incoming device control over the insecure legacy admin channel",
label: "Admin Settings", },
description: "Settings for Admin ", {
fields: [ type: "toggle",
{ name: "isManaged",
type: "toggle", label: "Managed",
name: "adminChannelEnabled", description:
label: "Allow Legacy Admin", 'If true, device is considered to be "managed" by a mesh administrator via admin messages',
description: },
"Allow incoming device control over the insecure legacy admin channel", {
}, type: "text",
{ name: "adminKey",
type: "toggle", label: "Admin Key",
name: "isManaged", description:
label: "Managed", "The public key authorized to send admin messages to this node",
description: validationText: adminKeyValidationText,
'If true, device is considered to be "managed" by a mesh administrator via admin messages', inputChange: adminKeyInputChangeEvent,
}, disabledBy: [
{ { fieldName: "adminChannelEnabled", invert: true },
type: "passwordGenerator", ],
name: "adminKey", properties: {
label: "Admin Key", value: adminKey,
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),
}, },
}, },
}, },
@ -231,5 +230,11 @@ export const Security = (): JSX.Element => {
}, },
]} ]}
/> />
<PkiRegenerateDialog
open={dialogOpen}
onOpenChange={() => setDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
); );
}; };

38
src/components/UI/Generator.tsx

@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
value: string; value: string;
variant: "default" | "invalid"; variant: "default" | "invalid";
buttonText?: string; buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void; selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>; buttonClick: React.MouseEventHandler<HTMLButtonElement>;
@ -35,6 +36,11 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
variant, variant,
value, value,
buttonText, buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange, selectChange,
inputChange, inputChange,
buttonClick, buttonClick,
@ -44,6 +50,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
}, },
ref, ref,
) => { ) => {
const inputRef = React.useRef<HTMLInputElement>(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 ( return (
<> <>
<Input <Input
@ -54,30 +75,29 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
onChange={inputChange} onChange={inputChange}
action={action} action={action}
disabled={disabled} disabled={disabled}
ref={inputRef}
/> />
<Select <Select
value={devicePSKBitCount?.toString()} value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)} onValueChange={(e) => selectChange(e)}
disabled={disabled}
> >
<SelectTrigger className="!max-w-max"> <SelectTrigger className="!max-w-max">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem key="bit256" value="32"> {bits.map(({ text, value, key }) => (
256 bit <SelectItem key={key} value={value}>
</SelectItem> {text}
<SelectItem key="bit128" value="16"> </SelectItem>
128 bit ))}
</SelectItem>
<SelectItem key="bit8" value="1">
8 bit
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
type="button" type="button"
variant="success" variant="success"
onClick={buttonClick} onClick={buttonClick}
disabled={disabled}
{...props} {...props}
> >
{buttonText} {buttonText}

3
src/components/UI/Input.tsx

@ -31,7 +31,7 @@ export interface InputProps
} }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, variant, prefix, suffix, action, ...props }, ref) => { ({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{prefix && ( {prefix && (
@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className, className,
inputVariants({ variant }), inputVariants({ variant }),
)} )}
value={value}
ref={ref} ref={ref}
{...props} {...props}
/> />

15
src/core/utils/x25519.ts

@ -0,0 +1,15 @@
import { x25519 } from "@noble/curves/ed25519";
export function getX25519PrivateKey(): Uint8Array {
const key = x25519.utils.randomPrivateKey();
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
return key;
}
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
return x25519.getPublicKey(privateKey);
}
Loading…
Cancel
Save