Files
floci-ui/packages/frontend/src/components/KmsCryptoPanel.tsx
T
2026-09-30 17:43:42 -04:00

292 lines
11 KiB
TypeScript

import {useEffect, useRef, useState} from "react";
import {Copy, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
import {
decryptKmsResource,
encryptKmsResource,
type KmsEncryptionAlgorithm,
} from "@/api/cloudProxyClient";
import {useAccountId} from "@/lib/accountStore";
import type {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
import {ServiceIcon} from "@/components/serviceIcons";
interface KmsCryptoPanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
type CryptoMode = "encrypt" | "decrypt";
type CryptoResult = {label: string; value: string};
export function KmsCryptoPanel({cloud, resource, runtimeReachable}: KmsCryptoPanelProps) {
const accountId = useAccountId();
const keySpec = metadataString(resource, "keySpec");
const keyUsage = metadataString(resource, "keyUsage");
const keyEnabled = resource?.status === "Enabled" && resource.metadata.enabled === true;
const [mode, setMode] = useState<CryptoMode>("encrypt");
const [plaintext, setPlaintext] = useState("");
const [ciphertext, setCiphertext] = useState("");
const [algorithm, setAlgorithm] = useState<KmsEncryptionAlgorithm>(defaultAlgorithm(keySpec));
const [contextText, setContextText] = useState("");
const [result, setResult] = useState<CryptoResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const activeRequest = useRef<AbortController | null>(null);
useEffect(() => {
activeRequest.current?.abort();
activeRequest.current = null;
setMode("encrypt");
setPlaintext("");
setCiphertext("");
setAlgorithm(defaultAlgorithm(keySpec));
setContextText("");
setResult(null);
setError(null);
setPending(false);
setCopied(false);
return () => {
activeRequest.current?.abort();
activeRequest.current = null;
};
}, [accountId, resource?.id, keySpec]);
const isKmsKey = resource?.service === "kms" && resource.type === "key";
const isSupportedKey = keyUsage === "ENCRYPT_DECRYPT" && keyEnabled && isEncryptKeySpec(keySpec);
const canSubmit = Boolean(isKmsKey && isSupportedKey && runtimeReachable && !pending);
const isRsa = keySpec === "RSA_2048" || keySpec === "RSA_4096";
const changeMode = (next: CryptoMode) => {
if (next === mode) return;
activeRequest.current?.abort();
activeRequest.current = null;
setMode(next);
setPlaintext("");
setCiphertext("");
setContextText("");
setResult(null);
setError(null);
setPending(false);
setCopied(false);
};
const submit = async () => {
if (!resource || !canSubmit) return;
setError(null);
setResult(null);
setCopied(false);
activeRequest.current?.abort();
const controller = new AbortController();
activeRequest.current = controller;
setPending(true);
try {
const encryptionContext = isRsa ? undefined : parseEncryptionContext(contextText);
if (mode === "encrypt") {
if (!plaintext) throw new Error("Enter plaintext to encrypt.");
const response = await encryptKmsResource(cloud, resource.id, {
plaintextBase64: utf8ToBase64(plaintext),
encryptionAlgorithm: algorithm,
...(encryptionContext ? {encryptionContext} : {}),
}, controller.signal);
if (controller.signal.aborted) return;
setResult({label: "Ciphertext (Base64)", value: response.ciphertextBlobBase64});
} else {
if (!ciphertext.trim()) throw new Error("Enter Base64 ciphertext to decrypt.");
const response = await decryptKmsResource(cloud, resource.id, {
ciphertextBlobBase64: ciphertext.trim(),
encryptionAlgorithm: algorithm,
...(encryptionContext ? {encryptionContext} : {}),
}, controller.signal);
if (controller.signal.aborted) return;
setResult(decryptedResult(response.plaintextBase64));
}
} catch (submitError) {
if (controller.signal.aborted) return;
setError(submitError instanceof Error ? submitError.message : "KMS operation failed.");
} finally {
if (activeRequest.current === controller) {
activeRequest.current = null;
setPending(false);
}
}
};
const copyResult = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
if (!resource || resource.service !== "kms") {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select an encryption key</h3>
<p>Select an enabled ENCRYPT_DECRYPT KMS key to encrypt or decrypt data.</p>
</div>
</section>
);
}
return (
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">KMS Actions</p>
<h3><ServiceIcon cloud="aws" iconKey="kms" size={18} /> Encrypt and decrypt</h3>
<p className="muted compact-text">
Values stay in this panel and are cleared when you switch keys or operations.
</p>
</div>
<span className={`runtime-state ${canSubmit ? "ready" : "pending"}`}>
{canSubmit ? "Ready" : operationUnavailableReason(keyUsage, keyEnabled, keySpec, runtimeReachable)}
</span>
</div>
<div className="resource-create-inline">
<div className="drawer-tabs">
<button type="button" className={`drawer-tab ${mode === "encrypt" ? "active" : ""}`} onClick={() => changeMode("encrypt")}>
<LockKeyhole size={13} /> Encrypt
</button>
<button type="button" className={`drawer-tab ${mode === "decrypt" ? "active" : ""}`} onClick={() => changeMode("decrypt")}>
<UnlockKeyhole size={13} /> Decrypt
</button>
</div>
<label className="metric-label" htmlFor="kms-encryption-algorithm">Encryption algorithm</label>
<select
id="kms-encryption-algorithm"
className="input"
value={algorithm}
disabled={!isRsa}
onChange={(event) => setAlgorithm(event.target.value as KmsEncryptionAlgorithm)}
>
{isRsa ? (
<>
<option value="RSAES_OAEP_SHA_256">RSAES_OAEP_SHA_256</option>
<option value="RSAES_OAEP_SHA_1">RSAES_OAEP_SHA_1</option>
</>
) : (
<option value="SYMMETRIC_DEFAULT">SYMMETRIC_DEFAULT</option>
)}
</select>
<label className="metric-label" htmlFor={`kms-${mode}-input`}>
{mode === "encrypt" ? "Plaintext (UTF-8)" : "Ciphertext (Base64)"}
</label>
<textarea
id={`kms-${mode}-input`}
className="json-editor"
value={mode === "encrypt" ? plaintext : ciphertext}
onChange={(event) => mode === "encrypt" ? setPlaintext(event.target.value) : setCiphertext(event.target.value)}
spellCheck={false}
autoComplete="off"
style={{minHeight: 120}}
/>
{!isRsa && (
<>
<label className="metric-label" htmlFor="kms-encryption-context">Encryption context (optional JSON)</label>
<textarea
id="kms-encryption-context"
className="json-editor"
value={contextText}
onChange={(event) => setContextText(event.target.value)}
spellCheck={false}
autoComplete="off"
placeholder={'{"purpose":"local-test"}'}
style={{minHeight: 72}}
/>
<p className="muted compact-text">Encryption context is authenticated metadata and may appear in audit records. Do not put secrets in it.</p>
</>
)}
<button className="button primary" type="button" disabled={!canSubmit} onClick={() => void submit()}>
{pending ? <Loader2 size={13} className="spin" /> : mode === "encrypt" ? <LockKeyhole size={13} /> : <UnlockKeyhole size={13} />}
{pending ? "Working" : mode === "encrypt" ? "Encrypt" : "Decrypt"}
</button>
{error && <p className="error-text compact-text">{error}</p>}
{result && (
<div className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">{result.label}</p>
<button className="button" type="button" onClick={() => void copyResult()}>
<Copy size={13} /> {copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="invoke-result success">{result.value}</pre>
</div>
)}
</div>
</section>
);
}
function metadataString(resource: CloudResource | undefined, key: string): string | null {
const value = resource?.metadata[key];
return typeof value === "string" ? value : null;
}
function defaultAlgorithm(keySpec: string | null): KmsEncryptionAlgorithm {
return keySpec === "RSA_2048" || keySpec === "RSA_4096"
? "RSAES_OAEP_SHA_256"
: "SYMMETRIC_DEFAULT";
}
function isEncryptKeySpec(keySpec: string | null): boolean {
return keySpec === "SYMMETRIC_DEFAULT" || keySpec === "RSA_2048" || keySpec === "RSA_4096";
}
function operationUnavailableReason(
keyUsage: string | null,
keyEnabled: boolean,
keySpec: string | null,
runtimeReachable: boolean,
): string {
if (!runtimeReachable) return "Runtime unavailable";
if (keyUsage !== "ENCRYPT_DECRYPT") return "Unsupported key usage";
if (!keyEnabled) return "Key not enabled";
if (!isEncryptKeySpec(keySpec)) return "Unsupported key spec";
return "Unavailable";
}
function parseEncryptionContext(raw: string): Record<string, string> | undefined {
if (!raw.trim()) return undefined;
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("Encryption context must be valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Encryption context must be a JSON object of string values.");
}
const entries = Object.entries(value);
if (entries.some(([, entryValue]) => typeof entryValue !== "string")) {
throw new Error("Encryption context values must be strings.");
}
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>;
}
function utf8ToBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window.btoa(binary);
}
function decryptedResult(plaintextBase64: string): CryptoResult {
try {
const binary = window.atob(plaintextBase64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return {label: "Plaintext (UTF-8)", value: new TextDecoder("utf-8", {fatal: true}).decode(bytes)};
} catch {
return {label: "Plaintext (Base64)", value: plaintextBase64};
}
}