Skip to content

Commit edf4cca

Browse files
committed
feat: add packet authenticity policy setting
1 parent a7f7e8f commit edf4cca

21 files changed

Lines changed: 672 additions & 23 deletions

apps/web/public/i18n/locales/en/config.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,26 @@
483483
"description": "Settings for the Security configuration",
484484
"title": "Security",
485485
"button_backupKey": "Backup Key",
486+
"packetAuthenticity": {
487+
"label": "Packet authenticity",
488+
"description": "Configure the radio's receive policy for every remote mesh packet it can decrypt.",
489+
"protectionLevel": "Protection level",
490+
"unavailable": "Unavailable while disconnected or when the firmware does not report XEdDSA verification support.",
491+
"options": {
492+
"compatible": {
493+
"label": "Compatible — Accept unsigned",
494+
"description": "Verify XEdDSA signatures when present, but accept unsigned remote packets for maximum compatibility."
495+
},
496+
"balanced": {
497+
"label": "Balanced — Prefer authenticated",
498+
"description": "Recommended. Reject unsigned, signable broadcasts from nodes known to sign while keeping legacy unsigned remote traffic."
499+
},
500+
"strict": {
501+
"label": "Strict — Require authenticated",
502+
"description": "Only show and process remote mesh packets verified by XEdDSA or successfully authenticated by PKI. Sender authentication does not prove coordinates or freshness."
503+
}
504+
}
505+
},
486506
"adminChannelEnabled": {
487507
"description": "Allow incoming device control over the insecure legacy admin channel",
488508
"label": "Legacy Admin channel"

apps/web/public/i18n/locales/en/dialog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
"description": "Are you sure you want to regenerate the pre-shared key?",
3030
"regenerate": "Regenerate"
3131
},
32+
"packetAuthenticityStrict": {
33+
"title": "Require authenticated packets?",
34+
"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.",
35+
"confirm": "Enable Strict"
36+
},
3237
"addConnection": {
3338
"title": "Add connection",
3439
"description": "Choose a connection type and fill in the details",

apps/web/src/components/Form/FormSelect.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
2222
enumValue: {
2323
[s: string]: string | number;
2424
};
25+
optionLabels?: Record<string, string>;
2526
formatEnumName?: boolean;
2627
};
2728
}
@@ -51,6 +52,7 @@ export function SelectInput<T extends FieldValues>({
5152

5253
const {
5354
enumValue,
55+
optionLabels,
5456
formatEnumName,
5557
defaultValue,
5658
className,
@@ -113,7 +115,8 @@ export function SelectInput<T extends FieldValues>({
113115
<SelectContent>
114116
{optionsEnumValues.map(([name, val]) => (
115117
<SelectItem key={name} value={val.toString()}>
116-
{formatEnumName ? formatEnumDisplay(name) : name}
118+
{optionLabels?.[name] ??
119+
(formatEnumName ? formatEnumDisplay(name) : name)}
117120
</SelectItem>
118121
))}
119122
</SelectContent>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Protobuf } from "@meshtastic/sdk";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { useForm } from "react-hook-form";
5+
import { describe, expect, it, vi } from "vitest";
6+
import type { RawSecurity } from "@app/validation/config/security.ts";
7+
import {
8+
PACKET_SIGNATURE_POLICY_OPTIONS,
9+
PacketAuthenticityPolicyField,
10+
} from "./PacketAuthenticityPolicyField.tsx";
11+
12+
const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;
13+
14+
const Harness = ({ supported }: { supported: boolean }) => {
15+
const { control } = useForm<RawSecurity>({
16+
defaultValues: { packetSignaturePolicy: Policy.BALANCED },
17+
});
18+
19+
return (
20+
<PacketAuthenticityPolicyField
21+
control={control}
22+
supported={supported}
23+
validateSelection={vi.fn().mockResolvedValue(true)}
24+
/>
25+
);
26+
};
27+
28+
describe("PacketAuthenticityPolicyField", () => {
29+
it("disables configuration and explains the unavailable capability", () => {
30+
render(<Harness supported={false} />);
31+
32+
expect(
33+
screen.getByRole("combobox", { name: "Protection level" }),
34+
).toBeDisabled();
35+
expect(
36+
screen.getByText(/disconnected.*XEdDSA verification support/i),
37+
).toBeInTheDocument();
38+
});
39+
40+
it("presents Compatible, Balanced, and Strict in explicit product order", async () => {
41+
const user = userEvent.setup();
42+
render(<Harness supported />);
43+
44+
expect(Object.keys(PACKET_SIGNATURE_POLICY_OPTIONS)).toEqual([
45+
"COMPATIBLE",
46+
"BALANCED",
47+
"STRICT",
48+
]);
49+
50+
await user.click(
51+
screen.getByRole("combobox", { name: "Protection level" }),
52+
);
53+
54+
expect(
55+
screen.getAllByRole("option").map((option) => option.textContent),
56+
).toEqual([
57+
"Compatible — Accept unsigned",
58+
"Balanced — Prefer authenticated",
59+
"Strict — Require authenticated",
60+
]);
61+
});
62+
63+
it("shows Balanced downgrade-protection copy by default", () => {
64+
render(<Harness supported />);
65+
66+
expect(
67+
screen.getByText(
68+
/unsigned, signable broadcasts from nodes known to sign/i,
69+
),
70+
).toBeInTheDocument();
71+
});
72+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { SelectInput } from "@components/Form/FormSelect.tsx";
2+
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
3+
import { Protobuf } from "@meshtastic/sdk";
4+
import type { Control } from "react-hook-form";
5+
import { useWatch } from "react-hook-form";
6+
import { useTranslation } from "react-i18next";
7+
import type { RawSecurity } from "@app/validation/config/security.ts";
8+
import { PACKET_SIGNATURE_POLICY_OPTIONS } from "./packetAuthenticityPolicy.ts";
9+
10+
const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;
11+
12+
export { PACKET_SIGNATURE_POLICY_OPTIONS } from "./packetAuthenticityPolicy.ts";
13+
14+
const POLICY_DESCRIPTION_KEYS: Record<
15+
Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy,
16+
string
17+
> = {
18+
[Policy.COMPATIBLE]:
19+
"security.packetAuthenticity.options.compatible.description",
20+
[Policy.BALANCED]: "security.packetAuthenticity.options.balanced.description",
21+
[Policy.STRICT]: "security.packetAuthenticity.options.strict.description",
22+
};
23+
24+
interface PacketAuthenticityPolicyFieldProps {
25+
control: Control<RawSecurity>;
26+
supported: boolean;
27+
validateSelection: (policyKey: string) => Promise<boolean>;
28+
}
29+
30+
export const PacketAuthenticityPolicyField = ({
31+
control,
32+
supported,
33+
validateSelection,
34+
}: PacketAuthenticityPolicyFieldProps) => {
35+
const { t } = useTranslation("config");
36+
const selectedPolicy = useWatch({
37+
control,
38+
name: "packetSignaturePolicy",
39+
});
40+
const description = supported
41+
? t(POLICY_DESCRIPTION_KEYS[selectedPolicy] ?? POLICY_DESCRIPTION_KEYS[0])
42+
: t("security.packetAuthenticity.unavailable");
43+
44+
return (
45+
<FieldWrapper
46+
label={t("security.packetAuthenticity.protectionLevel")}
47+
fieldName="packetSignaturePolicy"
48+
description={description}
49+
>
50+
<SelectInput<RawSecurity>
51+
control={control}
52+
disabled={!supported}
53+
field={{
54+
type: "select",
55+
name: "packetSignaturePolicy",
56+
label: t("security.packetAuthenticity.protectionLevel"),
57+
validate: validateSelection,
58+
properties: {
59+
enumValue: PACKET_SIGNATURE_POLICY_OPTIONS,
60+
optionLabels: {
61+
COMPATIBLE: t(
62+
"security.packetAuthenticity.options.compatible.label",
63+
),
64+
BALANCED: t("security.packetAuthenticity.options.balanced.label"),
65+
STRICT: t("security.packetAuthenticity.options.strict.label"),
66+
},
67+
"aria-label": t("security.packetAuthenticity.protectionLevel"),
68+
},
69+
}}
70+
/>
71+
</FieldWrapper>
72+
);
73+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { PacketAuthenticityStrictDialog } from "./PacketAuthenticityStrictDialog.tsx";
4+
5+
describe("PacketAuthenticityStrictDialog", () => {
6+
it("explains Strict authentication and visibility consequences", () => {
7+
render(
8+
<PacketAuthenticityStrictDialog
9+
open
10+
onOpenChange={vi.fn()}
11+
onConfirm={vi.fn()}
12+
onCancel={vi.fn()}
13+
/>,
14+
);
15+
16+
expect(screen.getByText(/verified XEdDSA signature/i)).toBeInTheDocument();
17+
expect(
18+
screen.getByText(/successful PKI authentication/i),
19+
).toBeInTheDocument();
20+
expect(screen.getByText(/older firmware/i)).toBeInTheDocument();
21+
expect(screen.getByText(/licensed\/ham nodes/i)).toBeInTheDocument();
22+
expect(screen.getByText(/oversized unsigned packets/i)).toBeInTheDocument();
23+
});
24+
25+
it("exposes explicit confirm and cancel actions", () => {
26+
const onConfirm = vi.fn();
27+
const onCancel = vi.fn();
28+
render(
29+
<PacketAuthenticityStrictDialog
30+
open
31+
onOpenChange={vi.fn()}
32+
onConfirm={onConfirm}
33+
onCancel={onCancel}
34+
/>,
35+
);
36+
37+
fireEvent.click(screen.getByRole("button", { name: "Enable Strict" }));
38+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
39+
40+
expect(onConfirm).toHaveBeenCalledOnce();
41+
expect(onCancel).toHaveBeenCalledOnce();
42+
});
43+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { DialogWrapper } from "@components/Dialog/DialogWrapper.tsx";
2+
import { useTranslation } from "react-i18next";
3+
4+
interface PacketAuthenticityStrictDialogProps {
5+
open: boolean;
6+
onOpenChange: (open: boolean) => void;
7+
onConfirm: () => void;
8+
onCancel: () => void;
9+
}
10+
11+
export const PacketAuthenticityStrictDialog = ({
12+
open,
13+
onOpenChange,
14+
onConfirm,
15+
onCancel,
16+
}: PacketAuthenticityStrictDialogProps) => {
17+
const { t } = useTranslation("dialog");
18+
19+
return (
20+
<DialogWrapper
21+
open={open}
22+
onOpenChange={onOpenChange}
23+
type="confirm"
24+
title={t("packetAuthenticityStrict.title")}
25+
description={t("packetAuthenticityStrict.description")}
26+
confirmText={t("packetAuthenticityStrict.confirm")}
27+
cancelText={t("button.cancel")}
28+
onConfirm={onConfirm}
29+
onCancel={onCancel}
30+
/>
31+
);
32+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Protobuf } from "@meshtastic/sdk";
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { RawSecurity } from "@app/validation/config/security.ts";
4+
import {
5+
submitSecurityConfig,
6+
toFormShape,
7+
toSecurityPayload,
8+
} from "./Security.tsx";
9+
10+
const Policy = Protobuf.Config.Config_SecurityConfig_PacketSignaturePolicy;
11+
12+
const rawSecurity = (
13+
packetSignaturePolicy: RawSecurity["packetSignaturePolicy"],
14+
) =>
15+
({
16+
isManaged: false,
17+
adminChannelEnabled: false,
18+
debugLogApiEnabled: false,
19+
serialEnabled: true,
20+
packetSignaturePolicy,
21+
privateKey: "",
22+
publicKey: "",
23+
adminKey: ["", "", ""],
24+
}) satisfies RawSecurity;
25+
26+
describe("Security packet authenticity persistence", () => {
27+
it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
28+
"preserves policy %s when mapping firmware config into the form",
29+
(packetSignaturePolicy) => {
30+
const form = toFormShape({
31+
packetSignaturePolicy,
32+
} as Protobuf.Config.Config_SecurityConfig);
33+
34+
expect(form.packetSignaturePolicy).toBe(packetSignaturePolicy);
35+
},
36+
);
37+
38+
it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
39+
"preserves policy %s when mapping the form to a security payload",
40+
(packetSignaturePolicy) => {
41+
expect(
42+
toSecurityPayload(rawSecurity(packetSignaturePolicy))
43+
.packetSignaturePolicy,
44+
).toBe(packetSignaturePolicy);
45+
},
46+
);
47+
48+
it.each([Policy.COMPATIBLE, Policy.BALANCED, Policy.STRICT])(
49+
"submits policy %s through the security radio section",
50+
(packetSignaturePolicy) => {
51+
const setRadioSection = vi.fn();
52+
53+
submitSecurityConfig(setRadioSection, rawSecurity(packetSignaturePolicy));
54+
55+
expect(setRadioSection).toHaveBeenCalledWith(
56+
"security",
57+
expect.objectContaining({ packetSignaturePolicy }),
58+
);
59+
},
60+
);
61+
});

0 commit comments

Comments
 (0)