Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/web/public/i18n/locales/en/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,26 @@
"description": "Settings for the Security configuration",
"title": "Security",
"button_backupKey": "Backup Key",
"packetAuthenticity": {
"label": "Packet authenticity",
"description": "Configure the radio's receive policy for every remote mesh packet it can decrypt.",
"protectionLevel": "Protection level",
"unavailable": "Unavailable while disconnected or when the firmware does not report XEdDSA verification support.",
"options": {
"compatible": {
"label": "Compatible — Accept unsigned",
"description": "Verify XEdDSA signatures when present, but accept unsigned remote packets for maximum compatibility."
},
"balanced": {
"label": "Balanced — Prefer authenticated",
"description": "Recommended. Reject unsigned, signable broadcasts from nodes known to sign while keeping legacy unsigned remote traffic."
},
"strict": {
"label": "Strict — Require authenticated",
"description": "Only show and process remote mesh packets verified by XEdDSA or successfully authenticated by PKI. Sender authentication does not prove coordinates or freshness."
}
}
},
"adminChannelEnabled": {
"description": "Allow incoming device control over the insecure legacy admin channel",
"label": "Legacy Admin channel"
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/i18n/locales/en/dialog.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"description": "Are you sure you want to regenerate the pre-shared key?",
"regenerate": "Regenerate"
},
"packetAuthenticityStrict": {
"title": "Require authenticated packets?",
"description": "Strict accepts only remotely received mesh packets with a verified XEdDSA signature or successful PKI authentication. PKI-authenticated direct messages remain accepted. Older firmware, licensed/ham nodes without signing keys, and oversized unsigned packets may disappear, reducing mesh visibility. Authentication verifies a cryptographic identity; it does not prove that content is truthful or fresh.",
"confirm": "Enable Strict"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"addConnection": {
"title": "Add connection",
"description": "Choose a connection type and fill in the details",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/Form/FormSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
enumValue: {
[s: string]: string | number;
};
optionLabels?: Record<string, string>;
formatEnumName?: boolean;
};
}
Expand Down Expand Up @@ -51,6 +52,7 @@ export function SelectInput<T extends FieldValues>({

const {
enumValue,
optionLabels,
formatEnumName,
defaultValue,
className,
Expand Down Expand Up @@ -113,7 +115,8 @@ export function SelectInput<T extends FieldValues>({
<SelectContent>
{optionsEnumValues.map(([name, val]) => (
<SelectItem key={name} value={val.toString()}>
{formatEnumName ? formatEnumDisplay(name) : name}
{optionLabels?.[name] ??
(formatEnumName ? formatEnumDisplay(name) : name)}
</SelectItem>
))}
</SelectContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Protobuf } from "@meshtastic/sdk";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import type { RawSecurity } from "@app/validation/config/security.ts";
import {
PACKET_SIGNATURE_POLICY_OPTIONS,
PacketAuthenticityPolicyField,
} from "./PacketAuthenticityPolicyField.tsx";

const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;

const Harness = ({ supported }: { supported: boolean }) => {
const { control } = useForm<RawSecurity>({
defaultValues: { packetSignaturePolicy: Policy.BALANCED },
});

return (
<PacketAuthenticityPolicyField
control={control}
supported={supported}
validateSelection={vi.fn().mockResolvedValue(true)}
/>
);
};

describe("PacketAuthenticityPolicyField", () => {
it("disables configuration and explains the unavailable capability", () => {
render(<Harness supported={false} />);

expect(
screen.getByRole("combobox", { name: "Protection level" }),
).toBeDisabled();
expect(
screen.getByText(/disconnected.*XEdDSA verification support/i),
).toBeInTheDocument();
});

it("presents Compatible, Balanced, and Strict in explicit product order", async () => {
const user = userEvent.setup();
render(<Harness supported />);

expect(Object.keys(PACKET_SIGNATURE_POLICY_OPTIONS)).toEqual([
"COMPATIBLE",
"BALANCED",
"STRICT",
]);

await user.click(
screen.getByRole("combobox", { name: "Protection level" }),
);

expect(
screen.getAllByRole("option").map((option) => option.textContent),
).toEqual([
"Compatible — Accept unsigned",
"Balanced — Prefer authenticated",
"Strict — Require authenticated",
]);
});

it("shows Balanced downgrade-protection copy by default", () => {
render(<Harness supported />);

expect(
screen.getByText(
/unsigned, signable broadcasts from nodes known to sign/i,
),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { SelectInput } from "@components/Form/FormSelect.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Protobuf } from "@meshtastic/sdk";
import type { Control } from "react-hook-form";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { RawSecurity } from "@app/validation/config/security.ts";
import { PACKET_SIGNATURE_POLICY_OPTIONS } from "./packetAuthenticityPolicy.ts";

const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;

export { PACKET_SIGNATURE_POLICY_OPTIONS } from "./packetAuthenticityPolicy.ts";

const POLICY_DESCRIPTION_KEYS: Record<
Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy,
string
> = {
[Policy.COMPATIBLE]:
"security.packetAuthenticity.options.compatible.description",
[Policy.BALANCED]: "security.packetAuthenticity.options.balanced.description",
[Policy.STRICT]: "security.packetAuthenticity.options.strict.description",
};

interface PacketAuthenticityPolicyFieldProps {
control: Control<RawSecurity>;
supported: boolean;
validateSelection: (policyKey: string) => Promise<boolean>;
}

export const PacketAuthenticityPolicyField = ({
control,
supported,
validateSelection,
}: PacketAuthenticityPolicyFieldProps) => {
const { t } = useTranslation("config");
const selectedPolicy = useWatch({
control,
name: "packetSignaturePolicy",
});
const description = supported
? t(POLICY_DESCRIPTION_KEYS[selectedPolicy] ?? POLICY_DESCRIPTION_KEYS[0])
: t("security.packetAuthenticity.unavailable");

return (
<FieldWrapper
label={t("security.packetAuthenticity.protectionLevel")}
fieldName="packetSignaturePolicy"
description={description}
>
<SelectInput<RawSecurity>
control={control}
disabled={!supported}
field={{
type: "select",
name: "packetSignaturePolicy",
label: t("security.packetAuthenticity.protectionLevel"),
validate: validateSelection,
properties: {
enumValue: PACKET_SIGNATURE_POLICY_OPTIONS,
optionLabels: {
COMPATIBLE: t(
"security.packetAuthenticity.options.compatible.label",
),
BALANCED: t("security.packetAuthenticity.options.balanced.label"),
STRICT: t("security.packetAuthenticity.options.strict.label"),
},
"aria-label": t("security.packetAuthenticity.protectionLevel"),
},
}}
/>
</FieldWrapper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PacketAuthenticityStrictDialog } from "./PacketAuthenticityStrictDialog.tsx";

describe("PacketAuthenticityStrictDialog", () => {
it("explains Strict authentication and visibility consequences", () => {
render(
<PacketAuthenticityStrictDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(screen.getByText(/verified XEdDSA signature/i)).toBeInTheDocument();
expect(
screen.getByText(/successful PKI authentication/i),
).toBeInTheDocument();
expect(screen.getByText(/older firmware/i)).toBeInTheDocument();
expect(screen.getByText(/licensed\/ham nodes/i)).toBeInTheDocument();
expect(screen.getByText(/oversized unsigned packets/i)).toBeInTheDocument();
});

it("exposes explicit confirm and cancel actions", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<PacketAuthenticityStrictDialog
open
onOpenChange={vi.fn()}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Enable Strict" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));

expect(onConfirm).toHaveBeenCalledOnce();
expect(onCancel).toHaveBeenCalledOnce();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx";
import { useTranslation } from "react-i18next";

interface PacketAuthenticityStrictDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
onCancel: () => void;
}

export const PacketAuthenticityStrictDialog = ({
open,
onOpenChange,
onConfirm,
onCancel,
}: PacketAuthenticityStrictDialogProps) => {
const { t } = useTranslation("dialog");

return (
<DialogWrapper
open={open}
onOpenChange={onOpenChange}
type="confirm"
title={t("packetAuthenticityStrict.title")}
description={t("packetAuthenticityStrict.description")}
confirmText={t("packetAuthenticityStrict.confirm")}
cancelText={t("button.cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Protobuf } from "@meshtastic/sdk";
import { describe, expect, it, vi } from "vitest";
import type { RawSecurity } from "@app/validation/config/security.ts";
import {
submitSecurityConfig,
toFormShape,
toSecurityPayload,
} from "./Security.tsx";

const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;

const rawSecurity = (
packetSignaturePolicy: RawSecurity["packetSignaturePolicy"],
) =>
({
isManaged: false,
adminChannelEnabled: false,
debugLogApiEnabled: false,
serialEnabled: true,
packetSignaturePolicy,
privateKey: "",
publicKey: "",
adminKey: ["", "", ""],
}) satisfies RawSecurity;

describe("Security packet authenticity persistence", () => {
it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
"preserves policy %s when mapping firmware config into the form",
(packetSignaturePolicy) => {
const form = toFormShape({
packetSignaturePolicy,
} as Protobuf.Config.Config_SecurityConfig);

expect(form.packetSignaturePolicy).toBe(packetSignaturePolicy);
},
);

it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
"preserves policy %s when mapping the form to a security payload",
(packetSignaturePolicy) => {
expect(
toSecurityPayload(rawSecurity(packetSignaturePolicy))
.packetSignaturePolicy,
).toBe(packetSignaturePolicy);
},
);

it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
"submits policy %s through the security radio section",
(packetSignaturePolicy) => {
const setRadioSection = vi.fn();

submitSecurityConfig(setRadioSection, rawSecurity(packetSignaturePolicy));

expect(setRadioSection).toHaveBeenCalledWith(
"security",
expect.objectContaining({ packetSignaturePolicy }),
);
},
);
});
Loading
Loading