Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions src/interfaces/PostureCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PostureCheck {
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
certificate_check?: CertificateCheck;
};
policies?: Policy[];
active?: boolean;
Expand Down Expand Up @@ -65,6 +66,10 @@ export interface Process {
windows_path?: string;
}

export interface CertificateCheck {
ca_certificates: string[];
}

export const windowsKernelVersions: SelectOption[] = [
{ value: "10.0", label: "Windows 10" },
{ value: "10.0.2", label: "Windows 11" },
Expand Down
212 changes: 212 additions & 0 deletions src/modules/posture-checks/checks/PostureCheckCertificate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { Textarea } from "@components/Textarea";
import { uniqueId } from "lodash";
import {
ExternalLinkIcon,
FileKey2Icon,
MinusCircleIcon,
PlusCircle,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import {
isValidPEMCertificate,
useCertificateFingerprints,
} from "@/modules/posture-checks/helper/CertificateHelper";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";

type Props = {
value?: CertificateCheck;
onChange: (value: CertificateCheck | undefined) => void;
disabled?: boolean;
};

export const PostureCheckCertificate = ({
value,
onChange,
disabled,
}: Props) => {
const [open, setOpen] = useState(false);

return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.ca_certificates && value.ca_certificates.length > 0}
title={"Certificate"}
description={
"Restrict access to peers holding a certificate issued by your own certificate authority."
}
icon={<FileKey2Icon size={18} />}
iconClass={"bg-gradient-to-tr from-purple-500 to-purple-400"}
modalWidthClass={"max-w-2xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
disabled={disabled}
/>
</PostureCheckCard>
);
};

type CACertificate = {
id: string;
pem: string;
};

const newCertificate = (pem = ""): CACertificate => ({
id: uniqueId("ca-certificate"),
pem,
});

const rowsFor = (pem: string) =>
Math.min(Math.max(pem.split("\n").length + 1, 6), 32);

const CheckContent = ({ value, onChange, disabled }: Props) => {
const [certificates, setCertificates] = useState<CACertificate[]>(
value?.ca_certificates?.length
? value.ca_certificates.map((pem) => newCertificate(pem))
: [newCertificate()],
);

const pems = useMemo(() => certificates.map((c) => c.pem), [certificates]);
const fingerprints = useCertificateFingerprints(pems);

const errors = useMemo(
() =>
certificates.map((c) =>
c.pem.trim() !== "" && !isValidPEMCertificate(c.pem)
? "Please paste a valid PEM encoded certificate"
: "",
),
[certificates],
);

const hasErrorsOrIsEmpty =
certificates.length === 0 ||
certificates.some((c) => c.pem.trim() === "") ||
errors.some((e) => e !== "");

const updateCertificate = (id: string, pem: string) => {
setCertificates(certificates.map((c) => (c.id === id ? { ...c, pem } : c)));
};

const removeCertificate = (id: string) => {
setCertificates(certificates.filter((c) => c.id !== id));
};

return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>CA Certificates</Label>
<HelpText className={""}>
Paste the PEM encoded public certificate of the root or
intermediate CA that issues your device certificates. Peers will
only be allowed to connect if they hold a certificate issued by
one of these CAs and prove possession of its private key.
</HelpText>
</div>
</div>
{certificates.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full"}>
{certificates.map((c, index) => {
return (
<div key={c.id} className={"flex gap-2 items-start min-w-0"}>
<div className={"w-full flex flex-col gap-1.5 min-w-0"}>
<Textarea
value={c.pem}
rows={rowsFor(c.pem)}
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
error={errors[index]}
className={
"w-full font-mono text-xs leading-5 overflow-auto"
}
onChange={(e) => updateCertificate(c.id, e.target.value)}
disabled={disabled}
/>
{fingerprints[index] && (
<span
className={
"text-xs text-nb-gray-400 font-mono break-all"
}
>
SHA-256 {fingerprints[index]}
</span>
)}
</div>

<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeCertificate(c.id)}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={() => setCertificates([...certificates, newCertificate()])}
className={"mt-1"}
disabled={disabled}
>
<PlusCircle size={16} />
Add CA Certificate
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#certificate-check"
}
target={"_blank"}
>
Certificate Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty || disabled}
onClick={() =>
onChange({
ca_certificates: certificates.map((c) => c.pem.trim()),
})
}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};
73 changes: 73 additions & 0 deletions src/modules/posture-checks/checks/tooltips/CertificateTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import { FileKey2Icon } from "lucide-react";
import * as React from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import { useCertificateFingerprints } from "@/modules/posture-checks/helper/CertificateHelper";

type Props = {
check?: CertificateCheck;
children?: React.ReactNode;
};
export const CertificateTooltip = ({ check, children }: Props) => {
const fingerprints = useCertificateFingerprints(check?.ca_certificates ?? []);

return check ? (
<FullTooltip
className={"w-full min-w-0"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
}
>
<div className={"px-4 pt-3"}>
<span>
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
peers holding a certificate issued by one of the following CAs
</span>
</div>

<ScrollArea
className={
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
}
>
<div className={"flex flex-col gap-2 mt-1 text-xs mb-3.5 min-w-0"}>
{check.ca_certificates.map((_, index) => {
const fingerprint = fingerprints[index];
return (
<Badge
key={index}
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<FileKey2Icon size={12} />
</span>
<span
className={"truncate inline-block font-mono"}
title={fingerprint}
>
{fingerprint
? `SHA-256 ${fingerprint}`
: `CA certificate ${index + 1}`}
</span>
</Badge>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};
54 changes: 54 additions & 0 deletions src/modules/posture-checks/helper/CertificateHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";

const pemCertificateBlock =
/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g;
const base64 = /^[A-Za-z0-9+/]+={0,2}$/;

// Returns the base64 bodies of all CERTIFICATE blocks, or undefined when the input is
// not exclusively made of well-formed blocks.
export const pemCertificateBodies = (pem: string): string[] | undefined => {
const bodies: string[] = [];
const rest = pem.replace(pemCertificateBlock, (_, body: string) => {
bodies.push(body.replace(/\s+/g, ""));
return "";
});
if (bodies.length === 0 || rest.trim() !== "") return undefined;
const wellFormed = bodies.every(
(b) => b.length > 0 && b.length % 4 === 0 && base64.test(b),
);
return wellFormed ? bodies : undefined;
};

export const isValidPEMCertificate = (pem: string) =>
pemCertificateBodies(pem) !== undefined;

// SHA-256 fingerprint of the first certificate in the PEM, as colon separated hex.
export const certificateFingerprint = async (
pem: string,
): Promise<string | undefined> => {
const bodies = pemCertificateBodies(pem);
if (!bodies || typeof crypto === "undefined" || !crypto.subtle) return;
const der = Uint8Array.from(atob(bodies[0]), (c) => c.charCodeAt(0));
const digest = await crypto.subtle.digest("SHA-256", der);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(":");
};

export const useCertificateFingerprints = (pems: string[]) => {
const [fingerprints, setFingerprints] = useState<(string | undefined)[]>([]);
const key = JSON.stringify(pems);

useEffect(() => {
let cancelled = false;
const list: string[] = JSON.parse(key);
Promise.all(list.map(certificateFingerprint)).then((result) => {
if (!cancelled) setFingerprints(result);
});
return () => {
cancelled = true;
};
}, [key]);

return fingerprints;
};
16 changes: 15 additions & 1 deletion src/modules/posture-checks/modal/PostureCheckModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
import React, { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCertificate } from "@/modules/posture-checks/checks/PostureCheckCertificate";
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
Expand Down Expand Up @@ -56,7 +57,8 @@ export default function PostureCheckModal({
!!check?.checks?.geo_location_check ||
!!check?.checks?.os_version_check ||
!!check?.checks?.peer_network_range_check ||
!!check?.checks.process_check;
!!check?.checks.process_check ||
!!check?.checks?.certificate_check;
const canCreate =
!isEmpty(check?.name) &&
isAtLeastOneCheckEnabled &&
Expand Down Expand Up @@ -165,6 +167,18 @@ export default function PostureCheckModal({
!permission.policies.create || !permission.policies.update
}
/>
<PostureCheckCertificate
value={check?.checks?.certificate_check}
onChange={(v) =>
setCheck({
type: "certificate_check",
payload: v,
})
}
disabled={
!permission.policies.create || !permission.policies.update
}
/>
</>
</TabsContent>
<TabsContent value={"general"} className={"pb-8 px-8"}>
Expand Down
Loading
Loading