Merge pull request #3357 from Infisical/daniel/kms-sign-verify

feat(kms): sign & verify data
This commit is contained in:
Daniel Hougaard
2025-04-13 22:22:23 +04:00
committed by GitHub
49 changed files with 2334 additions and 171 deletions
@@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions {
Edit = "edit",
Delete = "delete",
Encrypt = "encrypt",
Decrypt = "decrypt"
Decrypt = "decrypt",
Sign = "sign",
Verify = "verify"
}
export enum ProjectPermissionKmipActions {
+27
View File
@@ -0,0 +1,27 @@
import { AsymmetricKeyAlgorithm, KmsKeyUsage, SymmetricKeyAlgorithm } from "@app/hooks/api/cmeks";
export const kmsKeyUsageOptions: Record<
KmsKeyUsage,
{
label: string;
tooltip: string;
}
> = {
[KmsKeyUsage.ENCRYPT_DECRYPT]: {
label: "Encrypt/Decrypt",
tooltip: "Use the key only to encrypt and decrypt data."
},
[KmsKeyUsage.SIGN_VERIFY]: {
label: "Sign/Verify",
tooltip:
"Key pairs for digital signing. Uses the private key for signing and the public key for verification."
}
};
export const keyUsageDefaultOption: Record<
KmsKeyUsage,
SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm
> = {
[KmsKeyUsage.ENCRYPT_DECRYPT]: SymmetricKeyAlgorithm.AES_GCM_256,
[KmsKeyUsage.SIGN_VERIFY]: AsymmetricKeyAlgorithm.RSA_4096
};
@@ -106,6 +106,10 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_CMEK]: "Get KMS key",
[EventType.CMEK_ENCRYPT]: "Encrypt with KMS key",
[EventType.CMEK_DECRYPT]: "Decrypt with KMS key",
[EventType.CMEK_SIGN]: "Sign with KMS key",
[EventType.CMEK_VERIFY]: "Verify with KMS key",
[EventType.CMEK_LIST_SIGNING_ALGORITHMS]: "List signing algorithms for KMS key",
[EventType.CMEK_GET_PUBLIC_KEY]: "Get public key for KMS key",
[EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]:
"Update SSO group to organization role mapping",
[EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping",
@@ -110,6 +110,10 @@ export enum EventType {
GET_CMEK = "get-cmek",
CMEK_ENCRYPT = "cmek-encrypt",
CMEK_DECRYPT = "cmek-decrypt",
CMEK_SIGN = "cmek-sign",
CMEK_VERIFY = "cmek-verify",
CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms",
CMEK_GET_PUBLIC_KEY = "cmek-get-public-key",
UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping",
GET_PROJECT_TEMPLATES = "get-project-templates",
@@ -8,6 +8,10 @@ import {
TCmekDecryptResponse,
TCmekEncrypt,
TCmekEncryptResponse,
TCmekSign,
TCmekSignResponse,
TCmekVerify,
TCmekVerifyResponse,
TCreateCmek,
TDeleteCmek,
TUpdateCmek
@@ -74,6 +78,44 @@ export const useCmekEncrypt = () => {
});
};
export const useCmekSign = () => {
return useMutation({
mutationFn: async ({
keyId,
data,
signingAlgorithm,
isBase64Encoded
}: TCmekSign & { isBase64Encoded: boolean }) => {
const res = await apiRequest.post<TCmekSignResponse>(`/api/v1/kms/keys/${keyId}/sign`, {
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
signingAlgorithm
});
return res.data;
}
});
};
export const useCmekVerify = () => {
return useMutation({
mutationFn: async ({
keyId,
data,
signature,
signingAlgorithm,
isBase64Encoded
}: TCmekVerify & { isBase64Encoded: boolean }) => {
const res = await apiRequest.post<TCmekVerifyResponse>(`/api/v1/kms/keys/${keyId}/verify`, {
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
signature,
signingAlgorithm
});
return res.data;
}
});
};
export const useCmekDecrypt = () => {
return useMutation({
mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => {
+59 -3
View File
@@ -1,10 +1,18 @@
import { z } from "zod";
import { OrderByDirection } from "@app/hooks/api/generic/types";
export enum KmsKeyUsage {
ENCRYPT_DECRYPT = "encrypt-decrypt",
SIGN_VERIFY = "sign-verify"
}
export type TCmek = {
id: string;
keyUsage: KmsKeyUsage;
name: string;
description?: string;
encryptionAlgorithm: EncryptionAlgorithm;
encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm;
projectId: string;
isDisabled: boolean;
isReserved: boolean;
@@ -17,7 +25,8 @@ export type TCmek = {
type ProjectRef = { projectId: string };
type KeyRef = { keyId: string };
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm"> & ProjectRef;
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm" | "keyUsage"> &
ProjectRef;
export type TUpdateCmek = KeyRef &
Partial<Pick<TCmek, "name" | "description" | "isDisabled">> &
ProjectRef;
@@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef;
export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean };
export type TCmekDecrypt = KeyRef & { ciphertext: string };
export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm };
export type TCmekVerify = KeyRef & {
data: string;
signature: string;
signingAlgorithm: SigningAlgorithm;
};
export type TProjectCmeksList = {
keys: TCmek[];
totalCount: number;
@@ -44,6 +60,18 @@ export type TCmekEncryptResponse = {
ciphertext: string;
};
export type TCmekSignResponse = {
signature: string;
keyId: string;
signingAlgorithm: SigningAlgorithm;
};
export type TCmekVerifyResponse = {
signatureValid: boolean;
keyId: string;
signingAlgorithm: SigningAlgorithm;
};
export type TCmekDecryptResponse = {
plaintext: string;
};
@@ -52,7 +80,35 @@ export enum CmekOrderBy {
Name = "name"
}
export enum EncryptionAlgorithm {
export enum AsymmetricKeyAlgorithm {
RSA_4096 = "RSA_4096",
ECC_NIST_P256 = "ECC_NIST_P256"
}
// Supported symmetric encrypt/decrypt algorithms
export enum SymmetricKeyAlgorithm {
AES_GCM_256 = "aes-256-gcm",
AES_GCM_128 = "aes-128-gcm"
}
export const AllowedEncryptionKeyAlgorithms = z.enum([
...Object.values(SymmetricKeyAlgorithm),
...Object.values(AsymmetricKeyAlgorithm)
] as [string, ...string[]]).options;
export enum SigningAlgorithm {
// RSA PSS algorithms
RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256",
RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384",
RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512",
// RSA PKCS#1 v1.5 algorithms
RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256",
RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384",
RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512",
// ECDSA algorithms
ECDSA_SHA_256 = "ECDSA_SHA_256",
ECDSA_SHA_384 = "ECDSA_SHA_384",
ECDSA_SHA_512 = "ECDSA_SHA_512"
}
+14
View File
@@ -0,0 +1,14 @@
const base64WithPadding =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/;
export const isBase64 = (str: string): boolean => {
if (typeof str !== "string") {
throw new TypeError("Expected a string");
}
if (str === "") return true;
const regex = base64WithPadding;
return regex.test(str);
};
@@ -15,13 +15,23 @@ import {
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms";
import {
AllowedEncryptionKeyAlgorithms,
AsymmetricKeyAlgorithm,
KmsKeyUsage,
SymmetricKeyAlgorithm,
TCmek,
useCreateCmek,
useUpdateCmek
} from "@app/hooks/api/cmeks";
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().max(500).optional(),
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms),
keyUsage: z.nativeEnum(KmsKeyUsage)
});
export type FormData = z.infer<typeof formSchema>;
@@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
control,
handleSubmit,
register,
setValue,
watch,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: cmek?.name,
description: cmek?.description,
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256,
keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT
}
});
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
const handleCreateCmek = async ({
encryptionAlgorithm,
name,
description,
keyUsage
}: FormData) => {
const mutation = isUpdate
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
: createCmek.mutateAsync({
projectId,
encryptionAlgorithm,
name,
description
description,
keyUsage,
encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm
});
try {
@@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
}
};
const selectedKeyUsage = watch("keyUsage");
return (
<form onSubmit={handleSubmit(handleCreateCmek)}>
<FormControl
@@ -93,23 +114,97 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
>
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
</FormControl>
{!isUpdate && (
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
<SelectItem value={value} key={`source-environment-${key}`}>
{key.replaceAll("_", "-")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<div className="flex w-full items-center gap-2">
{!isUpdate && (
<>
<Controller
control={control}
name="keyUsage"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
tooltipText={
<div className="space-y-4">
{Object.entries(KmsKeyUsage).map(([key, value]) => (
<div key={`key-usage-${key}`}>
<p className="font-bold">{kmsKeyUsageOptions[value].label}</p>
<p>{kmsKeyUsageOptions[value].tooltip}</p>
</div>
))}
</div>
}
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
onValueChange={(e) => {
if (keyUsageDefaultOption[e as KmsKeyUsage]) {
setValue("encryptionAlgorithm", keyUsageDefaultOption[e as KmsKeyUsage], {
shouldDirty: true,
shouldValidate: true
});
}
onChange(e);
}}
className="w-full"
>
{Object.entries(KmsKeyUsage)?.map(([key, value]) => (
<SelectItem value={value} key={`key-usage-${key}`}>
{kmsKeyUsageOptions[value].label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
value={field.value}
onValueChange={onChange}
className="w-full"
>
{Object.entries(AllowedEncryptionKeyAlgorithms)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
?.filter(([_, value]) => {
if (selectedKeyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) {
return Object.values(SymmetricKeyAlgorithm).includes(
value as unknown as SymmetricKeyAlgorithm
);
}
if (selectedKeyUsage === KmsKeyUsage.SIGN_VERIFY) {
return Object.values(AsymmetricKeyAlgorithm).includes(
value as unknown as AsymmetricKeyAlgorithm
);
}
return false;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([_, value]) => (
<SelectItem value={value} key={`encryption-algorithm-${value}`}>
<span className="uppercase">{value.replaceAll("-", " ")}</span>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
)}
</div>
<FormControl
label="Description (optional)"
errorText={errors.description?.message}
@@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle, faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { SigningAlgorithm, TCmek, useCmekSign } from "@app/hooks/api/cmeks";
const formSchema = z.object({
data: z.string(),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const SignForm = ({ cmek }: FormProps) => {
const cmekSign = useCmekSign();
const {
handleSubmit,
register,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset<string>({
initialState: "Copy to Clipboard"
});
const handleSignData = async (formData: FormData) => {
try {
await cmekSign.mutateAsync({ ...formData, keyId: cmek.id });
createNotification({
text: "Successfully signed data",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = cmekSign.data?.signature;
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(signature ?? "");
setCopySignature("Copied to Clipboard");
};
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleSignData)}>
{signature ? (
<FormControl label="Data Signature">
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
isDisabled
value={signature}
/>
</FormControl>
) : (
<>
<FormControl
label="Data to Sign"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
<Button
className={`mr-4 ${signature ? "w-44" : ""}`}
size="sm"
leftIcon={
// eslint-disable-next-line no-nested-ternary
signature ? (
isCopyingSignature ? (
<FontAwesomeIcon icon={faCheckCircle} />
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
}
onClick={signature ? handleCopyToClipboard : undefined}
type={signature ? "button" : "submit"}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{signature ? copySignature : "Sign"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
{signature ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekSignModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Sign Data"
subTitle={
<>
Sign data using <span className="font-bold">{cmek?.name}</span>. Returns a Base64
encoded signature.
</>
}
>
<SignForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -8,6 +8,7 @@ import {
faCopy,
faEdit,
faEllipsis,
faFileSignature,
faInfoCircle,
faKey,
faLock,
@@ -51,14 +52,17 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { CmekDecryptModal } from "./CmekDecryptModal";
import { CmekEncryptModal } from "./CmekEncryptModal";
import { CmekModal } from "./CmekModal";
import { CmekSignModal } from "./CmekSignModal";
import { CmekVerifyModal } from "./CmekVerifyModal";
import { DeleteCmekModal } from "./DeleteCmekModal";
const getStatusBadgeProps = (
@@ -123,7 +127,9 @@ export const CmekTable = () => {
"upsertKey",
"deleteKey",
"encryptData",
"decryptData"
"decryptData",
"signData",
"verifyData"
] as const);
const handleSort = () => {
@@ -179,6 +185,15 @@ export const CmekTable = () => {
ProjectPermissionSub.Cmek
);
const cannotSignData = permission.cannot(
ProjectPermissionCmekActions.Sign,
ProjectPermissionSub.Cmek
);
const cannotVerifyData = permission.cannot(
ProjectPermissionCmekActions.Verify,
ProjectPermissionSub.Cmek
);
return (
<motion.div
key="kms-keys-tab"
@@ -246,6 +261,7 @@ export const CmekTable = () => {
</div>
</Th>
<Th>Key ID</Th>
<Th>Key Usage</Th>
<Th>Algorithm</Th>
<Th>Status</Th>
<Th>Version</Th>
@@ -257,7 +273,15 @@ export const CmekTable = () => {
{!isPending &&
keys.length > 0 &&
keys.map((cmek) => {
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
const {
name,
id,
version,
description,
encryptionAlgorithm,
isDisabled,
keyUsage
} = cmek;
const { variant, label } = getStatusBadgeProps(isDisabled);
return (
@@ -295,6 +319,14 @@ export const CmekTable = () => {
</IconButton>
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{kmsKeyUsageOptions[keyUsage].label}
<Tooltip content={kmsKeyUsageOptions[keyUsage].tooltip}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td className="uppercase">{encryptionAlgorithm}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
@@ -314,50 +346,104 @@ export const CmekTable = () => {
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[160px]">
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
{keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
{keyUsage === KmsKeyUsage.SIGN_VERIFY && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotSignData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("signData", cmek)}
icon={<FontAwesomeIcon icon={faFileSignature} />}
iconPos="left"
isDisabled={cannotSignData || isDisabled}
>
Sign Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotVerifyData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("verifyData", cmek)}
icon={<FontAwesomeIcon icon={faCheckCircle} />}
iconPos="left"
isDisabled={cannotVerifyData || isDisabled}
>
Verify Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
<Tooltip
content={cannotEditKey ? "Access Restricted" : ""}
position="left"
@@ -456,6 +542,16 @@ export const CmekTable = () => {
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
cmek={popUp.decryptData.data as TCmek}
/>
<CmekSignModal
isOpen={popUp.signData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("signData", isOpen)}
cmek={popUp.signData.data as TCmek}
/>
<CmekVerifyModal
isOpen={popUp.verifyData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("verifyData", isOpen)}
cmek={popUp.verifyData.data as TCmek}
/>
</div>
</motion.div>
);
@@ -0,0 +1,251 @@
import { Controller, useForm } from "react-hook-form";
import { faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { decodeBase64 } from "tweetnacl-util";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { SigningAlgorithm, TCmek, useCmekVerify } from "@app/hooks/api/cmeks";
import { isBase64 } from "@app/lib/fn/base64";
const formSchema = z.object({
data: z.string().min(1, { message: "Data cannot be empty" }),
signature: z
.string()
.min(1, { message: "Signature cannot be empty" })
.superRefine((val, ctx) => {
if (!isBase64(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Signature must be base64-encoded"
});
}
}),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const VerifyForm = ({ cmek }: FormProps) => {
const cmekVerify = useCmekVerify();
const {
handleSubmit,
register,
watch,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const handleVerifyData = async (formData: FormData) => {
try {
const result = await cmekVerify.mutateAsync({ ...formData, keyId: cmek.id });
if (result.signatureValid) {
createNotification({
text: "Successfully verified signature",
type: "success"
});
} else {
createNotification({
title: "Signature Verification Failed",
text: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with.",
type: "error"
});
}
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = watch("signature");
const data = watch("data");
const isBase64Encoded = watch("isBase64Encoded");
const signatureValid = cmekVerify.data?.signatureValid;
const signingAlgorithm = cmekVerify.data?.signingAlgorithm;
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleVerifyData)}>
{signatureValid !== undefined ? (
<div className="mb-6 flex flex-col gap-2">
<div className="flex items-center justify-between space-x-2">
<span className="text-sm opacity-60">Signature Status:</span>
<Badge variant={signatureValid ? "success" : "danger"}>
<Tooltip
content={
signatureValid
? "The signature is valid. signature was created using the same signing algorithm and key as the one used to sign the data."
: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with."
}
>
{signatureValid ? (
<div className="flex items-center justify-center gap-2">
<p>Valid</p>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<p>Invalid</p>
</div>
)}
</Tooltip>
</Badge>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-sm opacity-60">Signing Algorithm:</span>
<Badge variant="primary">{signingAlgorithm}</Badge>
</div>
<div className="mt-3">
<span className="text-sm opacity-60">Signature:</span>{" "}
<div className="whitespace-pre-wrap break-words rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{signature}
</div>
</div>
<div>
<span className="text-sm opacity-60">Data:</span>{" "}
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{isBase64Encoded ? decodeBase64(data).toString() : data}
</div>
</div>
</div>
) : (
<>
<FormControl
label="Data to Verify"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<FormControl
label="Signature of Data"
tooltipText="Must be base64-encoded, like the signature you received when you signed the data."
errorText={errors.signature?.message}
isError={Boolean(errors.signature)}
>
<TextArea
{...register("signature")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
{signatureValid === undefined && (
<Button
className="mr-4 w-44"
size="sm"
leftIcon={<FontAwesomeIcon icon={faFileSignature} />}
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Verify
</Button>
)}
<ModalClose asChild>
<Button
colorSchema={signatureValid === undefined ? "secondary" : "primary"}
variant={signatureValid === undefined ? "plain" : undefined}
>
{signatureValid !== undefined ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekVerifyModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Verify Signature"
subTitle={
<>
Verify a signature using <span className="font-bold">{cmek?.name}</span>.
</>
}
>
<VerifyForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -47,7 +47,9 @@ const CmekPolicyActionSchema = z.object({
delete: z.boolean().optional(),
create: z.boolean().optional(),
encrypt: z.boolean().optional(),
decrypt: z.boolean().optional()
decrypt: z.boolean().optional(),
sign: z.boolean().optional(),
verify: z.boolean().optional()
});
const DynamicSecretPolicyActionSchema = z.object({
@@ -482,6 +484,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
const canSign = action.includes(ProjectPermissionCmekActions.Sign);
const canVerify = action.includes(ProjectPermissionCmekActions.Verify);
if (!formVal[subject]) formVal[subject] = [{}];
@@ -492,6 +496,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
if (canSign) formVal[subject]![0].sign = true;
if (canVerify) formVal[subject]![0].verify = true;
return;
}
@@ -769,7 +775,9 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" },
{ label: "Encrypt", value: "encrypt" },
{ label: "Decrypt", value: "decrypt" }
{ label: "Decrypt", value: "decrypt" },
{ label: "Sign", value: "sign" },
{ label: "Verify", value: "verify" }
]
},
[ProjectPermissionSub.Kms]: {