From 2a599bff81f30cfc406c3e3159729b1d176d9a78 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 20 Jul 2026 16:01:40 -0400 Subject: [PATCH] feat(web): CA key escrow export and import with strong guards --- src/components/escrow-export-dialog.test.tsx | 93 ++++++++++ src/components/escrow-export-dialog.tsx | 178 +++++++++++++++++++ src/components/escrow-import-dialog.test.tsx | 85 +++++++++ src/components/escrow-import-dialog.tsx | 164 +++++++++++++++++ src/components/node-detail-panel.test.tsx | 60 +++++++ src/components/node-detail-panel.tsx | 24 +++ src/context/auth.tsx | 6 + src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts | 150 +++++++++++++++- src/lib/escrow.test.ts | 104 +++++++++++ src/lib/escrow.ts | 84 +++++++++ 10 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 src/components/escrow-export-dialog.test.tsx create mode 100644 src/components/escrow-export-dialog.tsx create mode 100644 src/components/escrow-import-dialog.test.tsx create mode 100644 src/components/escrow-import-dialog.tsx create mode 100644 src/components/node-detail-panel.test.tsx create mode 100644 src/lib/escrow.test.ts create mode 100644 src/lib/escrow.ts diff --git a/src/components/escrow-export-dialog.test.tsx b/src/components/escrow-export-dialog.test.tsx new file mode 100644 index 0000000..67bfa98 --- /dev/null +++ b/src/components/escrow-export-dialog.test.tsx @@ -0,0 +1,93 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { EscrowExportDialog } from "@/components/escrow-export-dialog"; + +const exportCAKey = vi.fn(); +vi.mock("@/lib/escrow", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + exportCAKey: (...args: unknown[]) => exportCAKey(...args), + generateStrongPassphrase: () => "Generated-Strong-Passphrase-1234", + }; +}); + +const STRONG = "correct-horse-battery-staple"; + +const exportButton = () => screen.getByRole("button", { name: /export key/i }); + +describe("EscrowExportDialog", () => { + beforeEach(() => { + exportCAKey.mockReset(); + exportCAKey.mockResolvedValue(new Uint8Array([1, 2, 3])); + // Stub the download side effects so the click does not touch the DOM/URL. + globalThis.URL.createObjectURL = vi.fn(() => "blob:stub"); + globalThis.URL.revokeObjectURL = vi.fn(); + }); + + it("keeps Export disabled until both a >= 18 passphrase and the typed confirmation are present", () => { + render(); + expect(exportButton()).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + expect(exportButton()).toBeDisabled(); // still no typed confirmation + + fireEvent.change(screen.getByLabelText(/confirm/i), { target: { value: "acme-root-01" } }); + expect(exportButton()).toBeEnabled(); + }); + + it("rejects a short passphrase in-UI and never enables Export", () => { + render(); + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: "short" } }); + fireEvent.change(screen.getByLabelText(/confirm/i), { target: { value: "EXPORT" } }); + expect(screen.getByText(/at least 18 characters/i)).toBeInTheDocument(); + expect(exportButton()).toBeDisabled(); + }); + + it("the generate button fills a passphrase that satisfies the length guard", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /generate strong passphrase/i })); + fireEvent.change(screen.getByLabelText(/confirm/i), { target: { value: "EXPORT" } }); + expect(exportButton()).toBeEnabled(); + }); + + it("on confirm calls exportCAKey and triggers a download", async () => { + render(); + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + fireEvent.change(screen.getByLabelText(/confirm/i), { target: { value: "acme-root-01" } }); + fireEvent.click(exportButton()); + + await waitFor(() => expect(exportCAKey).toHaveBeenCalledWith("acme-root-01", STRONG)); + await waitFor(() => expect(globalThis.URL.createObjectURL).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText(/backup downloaded/i)).toBeInTheDocument()); + }); + + it("surfaces an export error inline", async () => { + exportCAKey.mockRejectedValue(new Error("node refused export; TPM-backed")); + render(); + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + fireEvent.change(screen.getByLabelText(/confirm/i), { target: { value: "EXPORT" } }); + fireEvent.click(exportButton()); + + await waitFor(() => expect(screen.getByText(/TPM-backed/i)).toBeInTheDocument()); + }); +}); diff --git a/src/components/escrow-export-dialog.tsx b/src/components/escrow-export-dialog.tsx new file mode 100644 index 0000000..1ce0022 --- /dev/null +++ b/src/components/escrow-export-dialog.tsx @@ -0,0 +1,178 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { exportCAKey, generateStrongPassphrase, MIN_PASSPHRASE_LENGTH } from "@/lib/escrow"; + +// download triggers a browser download of the encrypted envelope without +// leaving a URL object behind. The envelope is opaque bytes; it is never +// rendered on screen. The bytes are copied into a fresh ArrayBuffer-backed view +// so the Blob part type is unambiguous. +const download = (filename: string, contents: Uint8Array): void => { + const copy = new Uint8Array(contents.length); + copy.set(contents); + const url = URL.createObjectURL(new Blob([copy], { type: "application/octet-stream" })); + const anchor = document.createElement("a"); + anchor.download = filename; + anchor.href = url; + anchor.click(); + URL.revokeObjectURL(url); +}; + +// EscrowExportDialog extracts a node's CA private key into an encrypted backup +// envelope. It is deliberately double-guarded: a >= 18-character passphrase +// (typed or generated) AND a typed confirmation (the node name or the word +// EXPORT) are both required before the button enables. On confirm it relays the +// backup through the manager and downloads the envelope; the passphrase is +// never rendered back or logged, and the envelope is downloaded, not displayed. +export const EscrowExportDialog = ({ + nodeName, + onClose, +}: { + nodeName: string; + onClose: () => void; +}) => { + const [passphrase, setPassphrase] = useState(""); + const [confirmText, setConfirmText] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const passphraseLongEnough = passphrase.length >= MIN_PASSPHRASE_LENGTH; + const confirmed = confirmText === nodeName || confirmText === "EXPORT"; + const ready = passphraseLongEnough && confirmed && !pending; + + const confirm = () => { + if (!ready) return; + setPending(true); + setError(null); + exportCAKey(nodeName, passphrase) + .then((envelope) => { + download(`${nodeName}-ca-backup.enc`, envelope); + // Clear the passphrase from state the moment it is no longer needed. + setPassphrase(""); + setConfirmText(""); + setDone(true); + setPending(false); + }) + .catch((error_: unknown) => { + setError(error_ instanceof Error ? error_.message : "Export failed"); + setPending(false); + }); + }; + + return ( +
+
e.stopPropagation()} + role="dialog" + > +
+

+ Export CA key +

+

{nodeName}

+
+ +
+

+ This extracts the CA private key into an encrypted backup. +

+

+ Store the passphrase safely. Without it the backup is unrecoverable, and anyone with + both the file and the passphrase can restore this CA elsewhere. +

+
+ + {done ? ( +

+ Encrypted backup downloaded. +

+ ) : ( + <> + + + {passphrase.length > 0 && !passphraseLongEnough ? ( +

+ Passphrase must be at least {MIN_PASSPHRASE_LENGTH} characters. +

+ ) : null} + + + + {error ? ( +

+ {error} +

+ ) : null} + + )} + +
+ + {done ? null : ( + + )} +
+
+
+ ); +}; diff --git a/src/components/escrow-import-dialog.test.tsx b/src/components/escrow-import-dialog.test.tsx new file mode 100644 index 0000000..0f876c0 --- /dev/null +++ b/src/components/escrow-import-dialog.test.tsx @@ -0,0 +1,85 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { EscrowImportDialog } from "@/components/escrow-import-dialog"; + +const importCAKey = vi.fn(); +vi.mock("@/lib/escrow", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, importCAKey: (...args: unknown[]) => importCAKey(...args) }; +}); + +const STRONG = "correct-horse-battery-staple"; + +const selectFile = () => { + const bytes = new Uint8Array([1, 2, 3]); + const file = new File([bytes], "backup.enc", { type: "application/octet-stream" }); + // jsdom's File does not implement arrayBuffer(); provide it so the dialog can + // read the selected file the way a real browser would. + Object.defineProperty(file, "arrayBuffer", { value: () => Promise.resolve(bytes.buffer) }); + fireEvent.change(screen.getByLabelText(/backup envelope file/i), { target: { files: [file] } }); +}; + +const importButton = () => screen.getByRole("button", { name: /import key/i }); + +describe("EscrowImportDialog", () => { + beforeEach(() => { + importCAKey.mockReset(); + importCAKey.mockResolvedValue({ issuerCn: "ACME Root CA", subjectCn: "ACME Sub CA" }); + }); + + it("requires an uploaded envelope and a >= 18 passphrase before enabling Import", async () => { + render(); + expect(importButton()).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + expect(importButton()).toBeDisabled(); // no file yet + + selectFile(); + await waitFor(() => expect(importButton()).toBeEnabled()); + }); + + it("calls importCAKey and shows the restored identity", async () => { + render(); + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + selectFile(); + await waitFor(() => expect(importButton()).toBeEnabled()); + fireEvent.click(importButton()); + + await waitFor(() => + expect(importCAKey).toHaveBeenCalledWith("acme-fresh-01", expect.any(Uint8Array), STRONG), + ); + await waitFor(() => expect(screen.getByText(/ACME Sub CA/)).toBeInTheDocument()); + }); + + it("surfaces the node's already-has-identity error inline", async () => { + importCAKey.mockRejectedValue( + new Error('node "acme-fresh-01" already has a CA identity; import only onto a fresh node'), + ); + render(); + fireEvent.change(screen.getByLabelText(/passphrase/i), { target: { value: STRONG } }); + selectFile(); + await waitFor(() => expect(importButton()).toBeEnabled()); + fireEvent.click(importButton()); + + await waitFor(() => expect(screen.getByText(/already has a CA identity/i)).toBeInTheDocument()); + }); +}); diff --git a/src/components/escrow-import-dialog.tsx b/src/components/escrow-import-dialog.tsx new file mode 100644 index 0000000..7ff67e8 --- /dev/null +++ b/src/components/escrow-import-dialog.tsx @@ -0,0 +1,164 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { importCAKey, MIN_PASSPHRASE_LENGTH, type RestoredIdentity } from "@/lib/escrow"; + +// EscrowImportDialog restores a CA identity onto a fresh node from an uploaded +// encrypted backup envelope. It requires both a chosen file and a >= 18 +// character passphrase before enabling. A node that already holds an identity +// refuses the import; that error surfaces inline (no native popup). The +// passphrase is never rendered back. +export const EscrowImportDialog = ({ + nodeName, + onClose, +}: { + nodeName: string; + onClose: () => void; +}) => { + const [envelope, setEnvelope] = useState(null); + const [fileName, setFileName] = useState(""); + const [passphrase, setPassphrase] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [restored, setRestored] = useState(null); + + const passphraseLongEnough = passphrase.length >= MIN_PASSPHRASE_LENGTH; + const ready = envelope !== null && envelope.length > 0 && passphraseLongEnough && !pending; + + const onFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + setEnvelope(null); + setFileName(""); + return; + } + setFileName(file.name); + file + .arrayBuffer() + .then((buffer) => setEnvelope(new Uint8Array(buffer))) + .catch(() => setError("Could not read the selected file.")); + }; + + const confirm = () => { + if (!ready || envelope === null) return; + setPending(true); + setError(null); + importCAKey(nodeName, envelope, passphrase) + .then((identity) => { + setPassphrase(""); + setRestored(identity); + setPending(false); + }) + .catch((error_: unknown) => { + setError(error_ instanceof Error ? error_.message : "Import failed"); + setPending(false); + }); + }; + + return ( +
+
e.stopPropagation()} + role="dialog" + > +
+

+ Import CA key +

+

{nodeName}

+
+ +

+ Restore a CA identity from an encrypted backup. Import only onto a fresh node with no + existing CA identity. +

+ + {restored ? ( +
+

Restored CA identity.

+

subject: {restored.subjectCn}

+

issuer: {restored.issuerCn}

+
+ ) : ( + <> + + + + {passphrase.length > 0 && !passphraseLongEnough ? ( +

+ Passphrase must be at least {MIN_PASSPHRASE_LENGTH} characters. +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + )} + +
+ + {restored ? null : ( + + )} +
+
+
+ ); +}; diff --git a/src/components/node-detail-panel.test.tsx b/src/components/node-detail-panel.test.tsx new file mode 100644 index 0000000..e0c3938 --- /dev/null +++ b/src/components/node-detail-panel.test.tsx @@ -0,0 +1,60 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NodeDetailPanel } from "@/components/node-detail-panel"; +import { mockNodes } from "@/lib/mock"; + +let level: "admin" | "operator" | "viewer" = "admin"; +vi.mock("@/context/auth", () => ({ + useOptionalAuth: () => ({ + operator: { commonName: "op@acme.example", level, serial: "AA" }, + status: "authenticated", + }), +})); + +const rootNode = () => mockNodes.find((n) => n.role === "root")!; + +const renderPanel = () => + render( + + + , + ); + +describe("NodeDetailPanel escrow actions", () => { + beforeEach(() => { + level = "admin"; + }); + + it("shows Export/Import key actions to an admin", () => { + renderPanel(); + expect(screen.getByRole("button", { name: /export key/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /import key/i })).toBeInTheDocument(); + }); + + it("hides escrow actions from a non-admin operator", () => { + level = "operator"; + renderPanel(); + expect(screen.queryByRole("button", { name: /export key/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /import key/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/node-detail-panel.tsx b/src/components/node-detail-panel.tsx index eee2d15..c62b674 100644 --- a/src/components/node-detail-panel.tsx +++ b/src/components/node-detail-panel.tsx @@ -16,10 +16,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { useState } from "react"; import { Link } from "react-router-dom"; +import { EscrowExportDialog } from "@/components/escrow-export-dialog"; +import { EscrowImportDialog } from "@/components/escrow-import-dialog"; import { IdentityBadge } from "@/components/identity-badge"; import { Button } from "@/components/ui/button"; +import { useOptionalAuth } from "@/context/auth"; import { canIssue } from "@/lib/certs"; import { type Node, roleLabels } from "@/lib/mock"; import { cn } from "@/lib/utils"; @@ -64,6 +68,9 @@ const fleetManagerText = (node: Node): string => { const DASH = "—"; export const NodeDetailPanel = ({ node }: { node: Node }) => { + const isAdmin = useOptionalAuth()?.operator?.level === "admin"; + const [dialog, setDialog] = useState<"export" | "import" | null>(null); + return (
{node.name}
@@ -118,7 +125,24 @@ export const NodeDetailPanel = ({ node }: { node: Node }) => { {"Re-key\u2026"} )} + {isAdmin ? ( + + ) : null} + {isAdmin ? ( + + ) : null}
+ + {dialog === "export" ? ( + setDialog(null)} /> + ) : null} + {dialog === "import" ? ( + setDialog(null)} /> + ) : null} ); }; diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 05bcd0a..a8878e4 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -100,3 +100,9 @@ export const useAuth = (): AuthState => { } return context; }; + +// useOptionalAuth reads the auth state without requiring a provider, returning +// null when there is none. Display components that embed in many contexts (the +// fleet list, the topology explorer, a standalone node view) use this to gate +// an admin-only action without forcing every render site to mount a provider. +export const useOptionalAuth = (): AuthState | null => useContext(AuthContext) ?? null; diff --git a/src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts b/src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts index bd1cf5f..85cf1e0 100644 --- a/src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts +++ b/src/gen/fleet/cryptos/fleet/v1/fleet_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file cryptos/fleet/v1/fleet.proto. */ export const file_cryptos_fleet_v1_fleet: GenFile = /*@__PURE__*/ - fileDesc("ChxjcnlwdG9zL2ZsZWV0L3YxL2ZsZWV0LnByb3RvEhBjcnlwdG9zLmZsZWV0LnYxIhIKEExpc3ROb2Rlc1JlcXVlc3QiQQoRTGlzdE5vZGVzUmVzcG9uc2USLAoFbm9kZXMYASADKAsyHS5jcnlwdG9zLmZsZWV0LnYxLk5vZGVTdW1tYXJ5Ih4KDkdldE5vZGVSZXF1ZXN0EgwKBG5hbWUYASABKAkiPQoPR2V0Tm9kZVJlc3BvbnNlEioKBG5vZGUYASABKAsyHC5jcnlwdG9zLmZsZWV0LnYxLk5vZGVEZXRhaWwiJwoXTGlzdENlcnRpZmljYXRlc1JlcXVlc3QSDAoEbm9kZRgBIAEoCSJPChhMaXN0Q2VydGlmaWNhdGVzUmVzcG9uc2USMwoMY2VydGlmaWNhdGVzGAEgAygLMh0uY3J5cHRvcy5mbGVldC52MS5DZXJ0aWZpY2F0ZSIVChNMaXN0UHJvZmlsZXNSZXF1ZXN0IkUKFExpc3RQcm9maWxlc1Jlc3BvbnNlEi0KBWl0ZW1zGAEgAygLMh4uY3J5cHRvcy52MS5DZXJ0aWZpY2F0ZVByb2ZpbGUiRwoUQ3JlYXRlUHJvZmlsZVJlcXVlc3QSLwoHcHJvZmlsZRgBIAEoCzIeLmNyeXB0b3MudjEuQ2VydGlmaWNhdGVQcm9maWxlIhcKFUNyZWF0ZVByb2ZpbGVSZXNwb25zZSJHChRVcGRhdGVQcm9maWxlUmVxdWVzdBIvCgdwcm9maWxlGAEgASgLMh4uY3J5cHRvcy52MS5DZXJ0aWZpY2F0ZVByb2ZpbGUiFwoVVXBkYXRlUHJvZmlsZVJlc3BvbnNlIiQKFERlbGV0ZVByb2ZpbGVSZXF1ZXN0EgwKBG5hbWUYASABKAkiFwoVRGVsZXRlUHJvZmlsZVJlc3BvbnNlIkQKGUFwcGx5UHJvZmlsZVRvTm9kZVJlcXVlc3QSEQoJbm9kZV9uYW1lGAEgASgJEhQKDHByb2ZpbGVfbmFtZRgCIAEoCSJJChpBcHBseVByb2ZpbGVUb05vZGVSZXNwb25zZRISCgpnZW5lcmF0aW9uGAEgASgEEhcKD3JlcXVpcmVzX3JlYm9vdBgCIAEoCCIVChNMaXN0QWRhcHRlcnNSZXF1ZXN0IkoKFExpc3RBZGFwdGVyc1Jlc3BvbnNlEjIKBWl0ZW1zGAEgAygLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50QWRhcHRlciI5ChhTZXRBZGFwdGVyRW5hYmxlZFJlcXVlc3QSDAoEbmFtZRgBIAEoCRIPCgdlbmFibGVkGAIgASgIIlEKGVNldEFkYXB0ZXJFbmFibGVkUmVzcG9uc2USNAoHYWRhcHRlchgBIAEoCzIjLmNyeXB0b3MuZmxlZXQudjEuRW5yb2xsbWVudEFkYXB0ZXIiEgoQTGlzdEF1ZGl0UmVxdWVzdCJAChFMaXN0QXVkaXRSZXNwb25zZRIrCgVpdGVtcxgBIAMoCzIcLmNyeXB0b3MuZmxlZXQudjEuQXVkaXRFdmVudCIYChZMaXN0RW5yb2xsbWVudHNSZXF1ZXN0Ik0KF0xpc3RFbnJvbGxtZW50c1Jlc3BvbnNlEjIKBWl0ZW1zGAEgAygLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50UmVxdWVzdCKvAQoLTm9kZVN1bW1hcnkSDAoEbmFtZRgBIAEoCRIPCgdhZGRyZXNzGAIgASgJEgwKBHJvbGUYAyABKAkSFgoOaWRlbnRpdHlfc3RhdGUYBCABKAkSCgoCY24YBSABKAkSDgoGaXNzdWVyGAYgASgJEigKBmhlYWx0aBgHIAEoDjIYLmNyeXB0b3MuZmxlZXQudjEuSGVhbHRoEhUKDWhlYWx0aF9kZXRhaWwYCCABKAkiSQoMTm9kZUlkZW50aXR5EhEKCWNoYWluX3BlbRgBIAEoCRIRCgljaGFpbl9kZXIYAiADKAwSEwoLbGVhZl9zaGEyNTYYAyABKAkiqQEKCk5vZGVEZXRhaWwSLgoHc3VtbWFyeRgBIAEoCzIdLmNyeXB0b3MuZmxlZXQudjEuTm9kZVN1bW1hcnkSMAoIaWRlbnRpdHkYAiABKAsyHi5jcnlwdG9zLmZsZWV0LnYxLk5vZGVJZGVudGl0eRIVCg10cG1fYXZhaWxhYmxlGAMgASgIEhIKCmJvb3RfY291bnQYBCABKAQSDgoGdXB0aW1lGAUgASgJIsABCgtDZXJ0aWZpY2F0ZRIOCgZzZXJpYWwYASABKAkSEgoKc3ViamVjdF9jbhgCIAEoCRITCgtpc3N1ZXJfbm9kZRgDIAEoCRIMCgRraW5kGAQgASgJEg4KBnN0YXR1cxgFIAEoCRISCgpub3RfYmVmb3JlGAYgASgJEhEKCW5vdF9hZnRlchgHIAEoCRIPCgdwcm9maWxlGAggASgJEhIKCnJldm9rZWRfYXQYCSABKAkSDgoGcmVhc29uGAogASgJIo0BChFFbnJvbGxtZW50QWRhcHRlchIMCgRraW5kGAEgASgJEgwKBG5hbWUYAiABKAkSEAoIZW5kcG9pbnQYAyABKAkSDwoHcHJvZmlsZRgEIAEoCRIPCgdlbmFibGVkGAUgASgIEhIKCmNoYWxsZW5nZXMYBiADKAkSFAoMZ3BvX3RlbXBsYXRlGAcgASgJIm0KCkF1ZGl0RXZlbnQSCgoCaWQYASABKAkSCgoCYXQYAiABKAkSDAoEa2luZBgDIAEoCRIPCgdzdW1tYXJ5GAQgASgJEhMKC3RhcmdldF9raW5kGAUgASgJEhMKC3RhcmdldF9wYXRoGAYgASgJItUCChFFbnJvbGxtZW50UmVxdWVzdBIKCgJpZBgBIAEoCRIVCg1wcm9wb3NlZF9uYW1lGAIgASgJEgwKBHJvbGUYAyABKAkSEQoJcGFyZW50X2NuGAQgASgJEg8KB2FkZHJlc3MYBSABKAkSDgoGc3RhdHVzGAYgASgJEhsKE2F0dGVzdGF0aW9uX3N1bW1hcnkYByABKAkSGwoTYXR0ZXN0YXRpb25fbm9kZV9pZBgIIAEoCRIUCgxjc3Jfa2V5X3R5cGUYCSABKAkSFgoOY3NyX3N1YmplY3RfY24YCiABKAkSFAoMcmVxdWVzdGVkX2F0GAsgASgJEhgKEHJlamVjdGlvbl9yZWFzb24YDCABKAkSGgoSYWRtaXR0ZWRfbm9kZV9uYW1lGA0gASgJEgwKBGtpbmQYDiABKAkSGQoRcGlubmVkX2tleV9zaGEyNTYYDyABKAkitQEKF0NyZWF0ZUVucm9sbG1lbnRSZXF1ZXN0EgwKBGtpbmQYASABKAkSFQoNbm9kZV9lbmRwb2ludBgCIAEoCRIWCg5hZG1pbl9jZXJ0X3BlbRgDIAEoCRIVCg1hZG1pbl9rZXlfcGVtGAQgASgJEg4KBmNhX3BlbRgFIAEoCRISCgpjaGlsZF9ub2RlGAYgASgJEhEKCXBhcmVudF9jbhgHIAEoCRIPCgdwcm9maWxlGAggASgJIlMKGENyZWF0ZUVucm9sbG1lbnRSZXNwb25zZRI3CgplbnJvbGxtZW50GAEgASgLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50UmVxdWVzdCJ8ChhBcHByb3ZlRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSFQoNbm9kZV9lbmRwb2ludBgCIAEoCRIWCg5hZG1pbl9jZXJ0X3BlbRgDIAEoCRIVCg1hZG1pbl9rZXlfcGVtGAQgASgJEg4KBmNhX3BlbRgFIAEoCSJUChlBcHByb3ZlRW5yb2xsbWVudFJlc3BvbnNlEjcKCmVucm9sbG1lbnQYASABKAsyIy5jcnlwdG9zLmZsZWV0LnYxLkVucm9sbG1lbnRSZXF1ZXN0IjUKF1JlamVjdEVucm9sbG1lbnRSZXF1ZXN0EgoKAmlkGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJTChhSZWplY3RFbnJvbGxtZW50UmVzcG9uc2USNwoKZW5yb2xsbWVudBgBIAEoCzIjLmNyeXB0b3MuZmxlZXQudjEuRW5yb2xsbWVudFJlcXVlc3QiDwoNV2hvQW1JUmVxdWVzdCI9ChBPcGVyYXRvcklkZW50aXR5EgoKAmNuGAEgASgJEg4KBnNlcmlhbBgCIAEoCRINCgVsZXZlbBgDIAEoCSJGCg5XaG9BbUlSZXNwb25zZRI0CghvcGVyYXRvchgBIAEoCzIiLmNyeXB0b3MuZmxlZXQudjEuT3BlcmF0b3JJZGVudGl0eSJWChhSZXZva2VDZXJ0aWZpY2F0ZVJlcXVlc3QSEQoJbm9kZV9uYW1lGAEgASgJEhIKCnNlcmlhbF9oZXgYAiABKAkSEwoLcmVhc29uX2NvZGUYAyABKAUiWAoZUmV2b2tlQ2VydGlmaWNhdGVSZXNwb25zZRISCgpzZXJpYWxfaGV4GAEgASgJEhIKCnJldm9rZWRfYXQYAiABKAkSEwoLcmVhc29uX2NvZGUYAyABKAUiTAoQSXNzdWVMZWFmUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkSDwoHY3NyX2RlchgCIAEoDBIUCgxwcm9maWxlX25hbWUYAyABKAkiJQoRSXNzdWVMZWFmUmVzcG9uc2USEAoIY2VydF9kZXIYASABKAwiOwoQUmVrZXlOb2RlUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkSFAoMcHJvZmlsZV9uYW1lGAIgASgJIk0KEVJla2V5Tm9kZVJlc3BvbnNlEhIKCnN1YmplY3RfY24YASABKAkSEQoJaXNzdWVyX2NuGAIgASgJEhEKCWNoYWluX2xlbhgDIAEoBSIpChRHZXROb2RlQ29uZmlnUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkiQgoVR2V0Tm9kZUNvbmZpZ1Jlc3BvbnNlEikKBmNvbmZpZxgBIAEoCzIZLmNyeXB0b3MudjEuTWFjaGluZUNvbmZpZyJWChZBcHBseU5vZGVDb25maWdSZXF1ZXN0EhEKCW5vZGVfbmFtZRgBIAEoCRIpCgZjb25maWcYAiABKAsyGS5jcnlwdG9zLnYxLk1hY2hpbmVDb25maWciRgoXQXBwbHlOb2RlQ29uZmlnUmVzcG9uc2USEgoKZ2VuZXJhdGlvbhgBIAEoBBIXCg9yZXF1aXJlc19yZWJvb3QYAiABKAgqUgoGSGVhbHRoEhYKEkhFQUxUSF9VTlNQRUNJRklFRBAAEg0KCUhFQUxUSF9VUBABEg8KC0hFQUxUSF9ET1dOEAISEAoMSEVBTFRIX0VSUk9SEAMylRAKDEZsZWV0U2VydmljZRJUCglMaXN0Tm9kZXMSIi5jcnlwdG9zLmZsZWV0LnYxLkxpc3ROb2Rlc1JlcXVlc3QaIy5jcnlwdG9zLmZsZWV0LnYxLkxpc3ROb2Rlc1Jlc3BvbnNlEk4KB0dldE5vZGUSIC5jcnlwdG9zLmZsZWV0LnYxLkdldE5vZGVSZXF1ZXN0GiEuY3J5cHRvcy5mbGVldC52MS5HZXROb2RlUmVzcG9uc2USaQoQTGlzdENlcnRpZmljYXRlcxIpLmNyeXB0b3MuZmxlZXQudjEuTGlzdENlcnRpZmljYXRlc1JlcXVlc3QaKi5jcnlwdG9zLmZsZWV0LnYxLkxpc3RDZXJ0aWZpY2F0ZXNSZXNwb25zZRJdCgxMaXN0UHJvZmlsZXMSJS5jcnlwdG9zLmZsZWV0LnYxLkxpc3RQcm9maWxlc1JlcXVlc3QaJi5jcnlwdG9zLmZsZWV0LnYxLkxpc3RQcm9maWxlc1Jlc3BvbnNlEmAKDUNyZWF0ZVByb2ZpbGUSJi5jcnlwdG9zLmZsZWV0LnYxLkNyZWF0ZVByb2ZpbGVSZXF1ZXN0GicuY3J5cHRvcy5mbGVldC52MS5DcmVhdGVQcm9maWxlUmVzcG9uc2USYAoNVXBkYXRlUHJvZmlsZRImLmNyeXB0b3MuZmxlZXQudjEuVXBkYXRlUHJvZmlsZVJlcXVlc3QaJy5jcnlwdG9zLmZsZWV0LnYxLlVwZGF0ZVByb2ZpbGVSZXNwb25zZRJgCg1EZWxldGVQcm9maWxlEiYuY3J5cHRvcy5mbGVldC52MS5EZWxldGVQcm9maWxlUmVxdWVzdBonLmNyeXB0b3MuZmxlZXQudjEuRGVsZXRlUHJvZmlsZVJlc3BvbnNlEm8KEkFwcGx5UHJvZmlsZVRvTm9kZRIrLmNyeXB0b3MuZmxlZXQudjEuQXBwbHlQcm9maWxlVG9Ob2RlUmVxdWVzdBosLmNyeXB0b3MuZmxlZXQudjEuQXBwbHlQcm9maWxlVG9Ob2RlUmVzcG9uc2USXQoMTGlzdEFkYXB0ZXJzEiUuY3J5cHRvcy5mbGVldC52MS5MaXN0QWRhcHRlcnNSZXF1ZXN0GiYuY3J5cHRvcy5mbGVldC52MS5MaXN0QWRhcHRlcnNSZXNwb25zZRJsChFTZXRBZGFwdGVyRW5hYmxlZBIqLmNyeXB0b3MuZmxlZXQudjEuU2V0QWRhcHRlckVuYWJsZWRSZXF1ZXN0GisuY3J5cHRvcy5mbGVldC52MS5TZXRBZGFwdGVyRW5hYmxlZFJlc3BvbnNlElQKCUxpc3RBdWRpdBIiLmNyeXB0b3MuZmxlZXQudjEuTGlzdEF1ZGl0UmVxdWVzdBojLmNyeXB0b3MuZmxlZXQudjEuTGlzdEF1ZGl0UmVzcG9uc2USZgoPTGlzdEVucm9sbG1lbnRzEiguY3J5cHRvcy5mbGVldC52MS5MaXN0RW5yb2xsbWVudHNSZXF1ZXN0GikuY3J5cHRvcy5mbGVldC52MS5MaXN0RW5yb2xsbWVudHNSZXNwb25zZRJpChBDcmVhdGVFbnJvbGxtZW50EikuY3J5cHRvcy5mbGVldC52MS5DcmVhdGVFbnJvbGxtZW50UmVxdWVzdBoqLmNyeXB0b3MuZmxlZXQudjEuQ3JlYXRlRW5yb2xsbWVudFJlc3BvbnNlEmwKEUFwcHJvdmVFbnJvbGxtZW50EiouY3J5cHRvcy5mbGVldC52MS5BcHByb3ZlRW5yb2xsbWVudFJlcXVlc3QaKy5jcnlwdG9zLmZsZWV0LnYxLkFwcHJvdmVFbnJvbGxtZW50UmVzcG9uc2USaQoQUmVqZWN0RW5yb2xsbWVudBIpLmNyeXB0b3MuZmxlZXQudjEuUmVqZWN0RW5yb2xsbWVudFJlcXVlc3QaKi5jcnlwdG9zLmZsZWV0LnYxLlJlamVjdEVucm9sbG1lbnRSZXNwb25zZRJLCgZXaG9BbUkSHy5jcnlwdG9zLmZsZWV0LnYxLldob0FtSVJlcXVlc3QaIC5jcnlwdG9zLmZsZWV0LnYxLldob0FtSVJlc3BvbnNlEmwKEVJldm9rZUNlcnRpZmljYXRlEiouY3J5cHRvcy5mbGVldC52MS5SZXZva2VDZXJ0aWZpY2F0ZVJlcXVlc3QaKy5jcnlwdG9zLmZsZWV0LnYxLlJldm9rZUNlcnRpZmljYXRlUmVzcG9uc2USVAoJSXNzdWVMZWFmEiIuY3J5cHRvcy5mbGVldC52MS5Jc3N1ZUxlYWZSZXF1ZXN0GiMuY3J5cHRvcy5mbGVldC52MS5Jc3N1ZUxlYWZSZXNwb25zZRJUCglSZWtleU5vZGUSIi5jcnlwdG9zLmZsZWV0LnYxLlJla2V5Tm9kZVJlcXVlc3QaIy5jcnlwdG9zLmZsZWV0LnYxLlJla2V5Tm9kZVJlc3BvbnNlEmAKDUdldE5vZGVDb25maWcSJi5jcnlwdG9zLmZsZWV0LnYxLkdldE5vZGVDb25maWdSZXF1ZXN0GicuY3J5cHRvcy5mbGVldC52MS5HZXROb2RlQ29uZmlnUmVzcG9uc2USZgoPQXBwbHlOb2RlQ29uZmlnEiguY3J5cHRvcy5mbGVldC52MS5BcHBseU5vZGVDb25maWdSZXF1ZXN0GikuY3J5cHRvcy5mbGVldC52MS5BcHBseU5vZGVDb25maWdSZXNwb25zZUI4WjZnaXRodWIuY29tL0NyeXB0T1MtUEtJL2FwaS9nby9jcnlwdG9zL2ZsZWV0L3YxO2ZsZWV0djFiBnByb3RvMw", [file_cryptos_v1_config]); + fileDesc("ChxjcnlwdG9zL2ZsZWV0L3YxL2ZsZWV0LnByb3RvEhBjcnlwdG9zLmZsZWV0LnYxIhIKEExpc3ROb2Rlc1JlcXVlc3QiQQoRTGlzdE5vZGVzUmVzcG9uc2USLAoFbm9kZXMYASADKAsyHS5jcnlwdG9zLmZsZWV0LnYxLk5vZGVTdW1tYXJ5Ih4KDkdldE5vZGVSZXF1ZXN0EgwKBG5hbWUYASABKAkiPQoPR2V0Tm9kZVJlc3BvbnNlEioKBG5vZGUYASABKAsyHC5jcnlwdG9zLmZsZWV0LnYxLk5vZGVEZXRhaWwiJwoXTGlzdENlcnRpZmljYXRlc1JlcXVlc3QSDAoEbm9kZRgBIAEoCSJPChhMaXN0Q2VydGlmaWNhdGVzUmVzcG9uc2USMwoMY2VydGlmaWNhdGVzGAEgAygLMh0uY3J5cHRvcy5mbGVldC52MS5DZXJ0aWZpY2F0ZSIVChNMaXN0UHJvZmlsZXNSZXF1ZXN0IkUKFExpc3RQcm9maWxlc1Jlc3BvbnNlEi0KBWl0ZW1zGAEgAygLMh4uY3J5cHRvcy52MS5DZXJ0aWZpY2F0ZVByb2ZpbGUiRwoUQ3JlYXRlUHJvZmlsZVJlcXVlc3QSLwoHcHJvZmlsZRgBIAEoCzIeLmNyeXB0b3MudjEuQ2VydGlmaWNhdGVQcm9maWxlIhcKFUNyZWF0ZVByb2ZpbGVSZXNwb25zZSJHChRVcGRhdGVQcm9maWxlUmVxdWVzdBIvCgdwcm9maWxlGAEgASgLMh4uY3J5cHRvcy52MS5DZXJ0aWZpY2F0ZVByb2ZpbGUiFwoVVXBkYXRlUHJvZmlsZVJlc3BvbnNlIiQKFERlbGV0ZVByb2ZpbGVSZXF1ZXN0EgwKBG5hbWUYASABKAkiFwoVRGVsZXRlUHJvZmlsZVJlc3BvbnNlIkQKGUFwcGx5UHJvZmlsZVRvTm9kZVJlcXVlc3QSEQoJbm9kZV9uYW1lGAEgASgJEhQKDHByb2ZpbGVfbmFtZRgCIAEoCSJJChpBcHBseVByb2ZpbGVUb05vZGVSZXNwb25zZRISCgpnZW5lcmF0aW9uGAEgASgEEhcKD3JlcXVpcmVzX3JlYm9vdBgCIAEoCCIVChNMaXN0QWRhcHRlcnNSZXF1ZXN0IkoKFExpc3RBZGFwdGVyc1Jlc3BvbnNlEjIKBWl0ZW1zGAEgAygLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50QWRhcHRlciI5ChhTZXRBZGFwdGVyRW5hYmxlZFJlcXVlc3QSDAoEbmFtZRgBIAEoCRIPCgdlbmFibGVkGAIgASgIIlEKGVNldEFkYXB0ZXJFbmFibGVkUmVzcG9uc2USNAoHYWRhcHRlchgBIAEoCzIjLmNyeXB0b3MuZmxlZXQudjEuRW5yb2xsbWVudEFkYXB0ZXIiEgoQTGlzdEF1ZGl0UmVxdWVzdCJAChFMaXN0QXVkaXRSZXNwb25zZRIrCgVpdGVtcxgBIAMoCzIcLmNyeXB0b3MuZmxlZXQudjEuQXVkaXRFdmVudCIYChZMaXN0RW5yb2xsbWVudHNSZXF1ZXN0Ik0KF0xpc3RFbnJvbGxtZW50c1Jlc3BvbnNlEjIKBWl0ZW1zGAEgAygLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50UmVxdWVzdCKvAQoLTm9kZVN1bW1hcnkSDAoEbmFtZRgBIAEoCRIPCgdhZGRyZXNzGAIgASgJEgwKBHJvbGUYAyABKAkSFgoOaWRlbnRpdHlfc3RhdGUYBCABKAkSCgoCY24YBSABKAkSDgoGaXNzdWVyGAYgASgJEigKBmhlYWx0aBgHIAEoDjIYLmNyeXB0b3MuZmxlZXQudjEuSGVhbHRoEhUKDWhlYWx0aF9kZXRhaWwYCCABKAkiSQoMTm9kZUlkZW50aXR5EhEKCWNoYWluX3BlbRgBIAEoCRIRCgljaGFpbl9kZXIYAiADKAwSEwoLbGVhZl9zaGEyNTYYAyABKAkiqQEKCk5vZGVEZXRhaWwSLgoHc3VtbWFyeRgBIAEoCzIdLmNyeXB0b3MuZmxlZXQudjEuTm9kZVN1bW1hcnkSMAoIaWRlbnRpdHkYAiABKAsyHi5jcnlwdG9zLmZsZWV0LnYxLk5vZGVJZGVudGl0eRIVCg10cG1fYXZhaWxhYmxlGAMgASgIEhIKCmJvb3RfY291bnQYBCABKAQSDgoGdXB0aW1lGAUgASgJIsABCgtDZXJ0aWZpY2F0ZRIOCgZzZXJpYWwYASABKAkSEgoKc3ViamVjdF9jbhgCIAEoCRITCgtpc3N1ZXJfbm9kZRgDIAEoCRIMCgRraW5kGAQgASgJEg4KBnN0YXR1cxgFIAEoCRISCgpub3RfYmVmb3JlGAYgASgJEhEKCW5vdF9hZnRlchgHIAEoCRIPCgdwcm9maWxlGAggASgJEhIKCnJldm9rZWRfYXQYCSABKAkSDgoGcmVhc29uGAogASgJIo0BChFFbnJvbGxtZW50QWRhcHRlchIMCgRraW5kGAEgASgJEgwKBG5hbWUYAiABKAkSEAoIZW5kcG9pbnQYAyABKAkSDwoHcHJvZmlsZRgEIAEoCRIPCgdlbmFibGVkGAUgASgIEhIKCmNoYWxsZW5nZXMYBiADKAkSFAoMZ3BvX3RlbXBsYXRlGAcgASgJIm0KCkF1ZGl0RXZlbnQSCgoCaWQYASABKAkSCgoCYXQYAiABKAkSDAoEa2luZBgDIAEoCRIPCgdzdW1tYXJ5GAQgASgJEhMKC3RhcmdldF9raW5kGAUgASgJEhMKC3RhcmdldF9wYXRoGAYgASgJItUCChFFbnJvbGxtZW50UmVxdWVzdBIKCgJpZBgBIAEoCRIVCg1wcm9wb3NlZF9uYW1lGAIgASgJEgwKBHJvbGUYAyABKAkSEQoJcGFyZW50X2NuGAQgASgJEg8KB2FkZHJlc3MYBSABKAkSDgoGc3RhdHVzGAYgASgJEhsKE2F0dGVzdGF0aW9uX3N1bW1hcnkYByABKAkSGwoTYXR0ZXN0YXRpb25fbm9kZV9pZBgIIAEoCRIUCgxjc3Jfa2V5X3R5cGUYCSABKAkSFgoOY3NyX3N1YmplY3RfY24YCiABKAkSFAoMcmVxdWVzdGVkX2F0GAsgASgJEhgKEHJlamVjdGlvbl9yZWFzb24YDCABKAkSGgoSYWRtaXR0ZWRfbm9kZV9uYW1lGA0gASgJEgwKBGtpbmQYDiABKAkSGQoRcGlubmVkX2tleV9zaGEyNTYYDyABKAkitQEKF0NyZWF0ZUVucm9sbG1lbnRSZXF1ZXN0EgwKBGtpbmQYASABKAkSFQoNbm9kZV9lbmRwb2ludBgCIAEoCRIWCg5hZG1pbl9jZXJ0X3BlbRgDIAEoCRIVCg1hZG1pbl9rZXlfcGVtGAQgASgJEg4KBmNhX3BlbRgFIAEoCRISCgpjaGlsZF9ub2RlGAYgASgJEhEKCXBhcmVudF9jbhgHIAEoCRIPCgdwcm9maWxlGAggASgJIlMKGENyZWF0ZUVucm9sbG1lbnRSZXNwb25zZRI3CgplbnJvbGxtZW50GAEgASgLMiMuY3J5cHRvcy5mbGVldC52MS5FbnJvbGxtZW50UmVxdWVzdCJ8ChhBcHByb3ZlRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSFQoNbm9kZV9lbmRwb2ludBgCIAEoCRIWCg5hZG1pbl9jZXJ0X3BlbRgDIAEoCRIVCg1hZG1pbl9rZXlfcGVtGAQgASgJEg4KBmNhX3BlbRgFIAEoCSJUChlBcHByb3ZlRW5yb2xsbWVudFJlc3BvbnNlEjcKCmVucm9sbG1lbnQYASABKAsyIy5jcnlwdG9zLmZsZWV0LnYxLkVucm9sbG1lbnRSZXF1ZXN0IjUKF1JlamVjdEVucm9sbG1lbnRSZXF1ZXN0EgoKAmlkGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJTChhSZWplY3RFbnJvbGxtZW50UmVzcG9uc2USNwoKZW5yb2xsbWVudBgBIAEoCzIjLmNyeXB0b3MuZmxlZXQudjEuRW5yb2xsbWVudFJlcXVlc3QiDwoNV2hvQW1JUmVxdWVzdCI9ChBPcGVyYXRvcklkZW50aXR5EgoKAmNuGAEgASgJEg4KBnNlcmlhbBgCIAEoCRINCgVsZXZlbBgDIAEoCSJGCg5XaG9BbUlSZXNwb25zZRI0CghvcGVyYXRvchgBIAEoCzIiLmNyeXB0b3MuZmxlZXQudjEuT3BlcmF0b3JJZGVudGl0eSJWChhSZXZva2VDZXJ0aWZpY2F0ZVJlcXVlc3QSEQoJbm9kZV9uYW1lGAEgASgJEhIKCnNlcmlhbF9oZXgYAiABKAkSEwoLcmVhc29uX2NvZGUYAyABKAUiWAoZUmV2b2tlQ2VydGlmaWNhdGVSZXNwb25zZRISCgpzZXJpYWxfaGV4GAEgASgJEhIKCnJldm9rZWRfYXQYAiABKAkSEwoLcmVhc29uX2NvZGUYAyABKAUiTAoQSXNzdWVMZWFmUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkSDwoHY3NyX2RlchgCIAEoDBIUCgxwcm9maWxlX25hbWUYAyABKAkiJQoRSXNzdWVMZWFmUmVzcG9uc2USEAoIY2VydF9kZXIYASABKAwiOwoQUmVrZXlOb2RlUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkSFAoMcHJvZmlsZV9uYW1lGAIgASgJIk0KEVJla2V5Tm9kZVJlc3BvbnNlEhIKCnN1YmplY3RfY24YASABKAkSEQoJaXNzdWVyX2NuGAIgASgJEhEKCWNoYWluX2xlbhgDIAEoBSIpChRHZXROb2RlQ29uZmlnUmVxdWVzdBIRCglub2RlX25hbWUYASABKAkiQgoVR2V0Tm9kZUNvbmZpZ1Jlc3BvbnNlEikKBmNvbmZpZxgBIAEoCzIZLmNyeXB0b3MudjEuTWFjaGluZUNvbmZpZyJWChZBcHBseU5vZGVDb25maWdSZXF1ZXN0EhEKCW5vZGVfbmFtZRgBIAEoCRIpCgZjb25maWcYAiABKAsyGS5jcnlwdG9zLnYxLk1hY2hpbmVDb25maWciRgoXQXBwbHlOb2RlQ29uZmlnUmVzcG9uc2USEgoKZ2VuZXJhdGlvbhgBIAEoBBIXCg9yZXF1aXJlc19yZWJvb3QYAiABKAgiOwoSRXhwb3J0Q0FLZXlSZXF1ZXN0EhEKCW5vZGVfbmFtZRgBIAEoCRISCgpwYXNzcGhyYXNlGAIgASgMIicKE0V4cG9ydENBS2V5UmVzcG9uc2USEAoIZW52ZWxvcGUYASABKAwiTQoSSW1wb3J0Q0FLZXlSZXF1ZXN0EhEKCW5vZGVfbmFtZRgBIAEoCRIQCghlbnZlbG9wZRgCIAEoDBISCgpwYXNzcGhyYXNlGAMgASgMIjwKE0ltcG9ydENBS2V5UmVzcG9uc2USEgoKc3ViamVjdF9jbhgBIAEoCRIRCglpc3N1ZXJfY24YAiABKAkqUgoGSGVhbHRoEhYKEkhFQUxUSF9VTlNQRUNJRklFRBAAEg0KCUhFQUxUSF9VUBABEg8KC0hFQUxUSF9ET1dOEAISEAoMSEVBTFRIX0VSUk9SEAMyzREKDEZsZWV0U2VydmljZRJUCglMaXN0Tm9kZXMSIi5jcnlwdG9zLmZsZWV0LnYxLkxpc3ROb2Rlc1JlcXVlc3QaIy5jcnlwdG9zLmZsZWV0LnYxLkxpc3ROb2Rlc1Jlc3BvbnNlEk4KB0dldE5vZGUSIC5jcnlwdG9zLmZsZWV0LnYxLkdldE5vZGVSZXF1ZXN0GiEuY3J5cHRvcy5mbGVldC52MS5HZXROb2RlUmVzcG9uc2USaQoQTGlzdENlcnRpZmljYXRlcxIpLmNyeXB0b3MuZmxlZXQudjEuTGlzdENlcnRpZmljYXRlc1JlcXVlc3QaKi5jcnlwdG9zLmZsZWV0LnYxLkxpc3RDZXJ0aWZpY2F0ZXNSZXNwb25zZRJdCgxMaXN0UHJvZmlsZXMSJS5jcnlwdG9zLmZsZWV0LnYxLkxpc3RQcm9maWxlc1JlcXVlc3QaJi5jcnlwdG9zLmZsZWV0LnYxLkxpc3RQcm9maWxlc1Jlc3BvbnNlEmAKDUNyZWF0ZVByb2ZpbGUSJi5jcnlwdG9zLmZsZWV0LnYxLkNyZWF0ZVByb2ZpbGVSZXF1ZXN0GicuY3J5cHRvcy5mbGVldC52MS5DcmVhdGVQcm9maWxlUmVzcG9uc2USYAoNVXBkYXRlUHJvZmlsZRImLmNyeXB0b3MuZmxlZXQudjEuVXBkYXRlUHJvZmlsZVJlcXVlc3QaJy5jcnlwdG9zLmZsZWV0LnYxLlVwZGF0ZVByb2ZpbGVSZXNwb25zZRJgCg1EZWxldGVQcm9maWxlEiYuY3J5cHRvcy5mbGVldC52MS5EZWxldGVQcm9maWxlUmVxdWVzdBonLmNyeXB0b3MuZmxlZXQudjEuRGVsZXRlUHJvZmlsZVJlc3BvbnNlEm8KEkFwcGx5UHJvZmlsZVRvTm9kZRIrLmNyeXB0b3MuZmxlZXQudjEuQXBwbHlQcm9maWxlVG9Ob2RlUmVxdWVzdBosLmNyeXB0b3MuZmxlZXQudjEuQXBwbHlQcm9maWxlVG9Ob2RlUmVzcG9uc2USXQoMTGlzdEFkYXB0ZXJzEiUuY3J5cHRvcy5mbGVldC52MS5MaXN0QWRhcHRlcnNSZXF1ZXN0GiYuY3J5cHRvcy5mbGVldC52MS5MaXN0QWRhcHRlcnNSZXNwb25zZRJsChFTZXRBZGFwdGVyRW5hYmxlZBIqLmNyeXB0b3MuZmxlZXQudjEuU2V0QWRhcHRlckVuYWJsZWRSZXF1ZXN0GisuY3J5cHRvcy5mbGVldC52MS5TZXRBZGFwdGVyRW5hYmxlZFJlc3BvbnNlElQKCUxpc3RBdWRpdBIiLmNyeXB0b3MuZmxlZXQudjEuTGlzdEF1ZGl0UmVxdWVzdBojLmNyeXB0b3MuZmxlZXQudjEuTGlzdEF1ZGl0UmVzcG9uc2USZgoPTGlzdEVucm9sbG1lbnRzEiguY3J5cHRvcy5mbGVldC52MS5MaXN0RW5yb2xsbWVudHNSZXF1ZXN0GikuY3J5cHRvcy5mbGVldC52MS5MaXN0RW5yb2xsbWVudHNSZXNwb25zZRJpChBDcmVhdGVFbnJvbGxtZW50EikuY3J5cHRvcy5mbGVldC52MS5DcmVhdGVFbnJvbGxtZW50UmVxdWVzdBoqLmNyeXB0b3MuZmxlZXQudjEuQ3JlYXRlRW5yb2xsbWVudFJlc3BvbnNlEmwKEUFwcHJvdmVFbnJvbGxtZW50EiouY3J5cHRvcy5mbGVldC52MS5BcHByb3ZlRW5yb2xsbWVudFJlcXVlc3QaKy5jcnlwdG9zLmZsZWV0LnYxLkFwcHJvdmVFbnJvbGxtZW50UmVzcG9uc2USaQoQUmVqZWN0RW5yb2xsbWVudBIpLmNyeXB0b3MuZmxlZXQudjEuUmVqZWN0RW5yb2xsbWVudFJlcXVlc3QaKi5jcnlwdG9zLmZsZWV0LnYxLlJlamVjdEVucm9sbG1lbnRSZXNwb25zZRJLCgZXaG9BbUkSHy5jcnlwdG9zLmZsZWV0LnYxLldob0FtSVJlcXVlc3QaIC5jcnlwdG9zLmZsZWV0LnYxLldob0FtSVJlc3BvbnNlEmwKEVJldm9rZUNlcnRpZmljYXRlEiouY3J5cHRvcy5mbGVldC52MS5SZXZva2VDZXJ0aWZpY2F0ZVJlcXVlc3QaKy5jcnlwdG9zLmZsZWV0LnYxLlJldm9rZUNlcnRpZmljYXRlUmVzcG9uc2USVAoJSXNzdWVMZWFmEiIuY3J5cHRvcy5mbGVldC52MS5Jc3N1ZUxlYWZSZXF1ZXN0GiMuY3J5cHRvcy5mbGVldC52MS5Jc3N1ZUxlYWZSZXNwb25zZRJUCglSZWtleU5vZGUSIi5jcnlwdG9zLmZsZWV0LnYxLlJla2V5Tm9kZVJlcXVlc3QaIy5jcnlwdG9zLmZsZWV0LnYxLlJla2V5Tm9kZVJlc3BvbnNlEmAKDUdldE5vZGVDb25maWcSJi5jcnlwdG9zLmZsZWV0LnYxLkdldE5vZGVDb25maWdSZXF1ZXN0GicuY3J5cHRvcy5mbGVldC52MS5HZXROb2RlQ29uZmlnUmVzcG9uc2USZgoPQXBwbHlOb2RlQ29uZmlnEiguY3J5cHRvcy5mbGVldC52MS5BcHBseU5vZGVDb25maWdSZXF1ZXN0GikuY3J5cHRvcy5mbGVldC52MS5BcHBseU5vZGVDb25maWdSZXNwb25zZRJaCgtFeHBvcnRDQUtleRIkLmNyeXB0b3MuZmxlZXQudjEuRXhwb3J0Q0FLZXlSZXF1ZXN0GiUuY3J5cHRvcy5mbGVldC52MS5FeHBvcnRDQUtleVJlc3BvbnNlEloKC0ltcG9ydENBS2V5EiQuY3J5cHRvcy5mbGVldC52MS5JbXBvcnRDQUtleVJlcXVlc3QaJS5jcnlwdG9zLmZsZWV0LnYxLkltcG9ydENBS2V5UmVzcG9uc2VCOFo2Z2l0aHViLmNvbS9DcnlwdE9TLVBLSS9hcGkvZ28vY3J5cHRvcy9mbGVldC92MTtmbGVldHYxYgZwcm90bzM", [file_cryptos_v1_config]); /** * @generated from message cryptos.fleet.v1.ListNodesRequest @@ -1340,6 +1340,124 @@ export type ApplyNodeConfigResponse = Message<"cryptos.fleet.v1.ApplyNodeConfigR export const ApplyNodeConfigResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_cryptos_fleet_v1_fleet, 49); +/** + * ExportCAKeyRequest names the managed node to back up and carries the operator + * passphrase the node seals the backup with. The passphrase is relayed to the + * node in transit and is never persisted by the manager. + * + * @generated from message cryptos.fleet.v1.ExportCAKeyRequest + */ +export type ExportCAKeyRequest = Message<"cryptos.fleet.v1.ExportCAKeyRequest"> & { + /** + * node_name is the managed node whose CA key to export. + * + * @generated from field: string node_name = 1; + */ + nodeName: string; + + /** + * passphrase seals the backup node-side; it is never persisted. + * + * @generated from field: bytes passphrase = 2; + */ + passphrase: Uint8Array; +}; + +/** + * Describes the message cryptos.fleet.v1.ExportCAKeyRequest. + * Use `create(ExportCAKeyRequestSchema)` to create a new message. + */ +export const ExportCAKeyRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cryptos_fleet_v1_fleet, 50); + +/** + * ExportCAKeyResponse carries the encrypted backup envelope (Argon2id + + * AES-256-GCM over the node's CA key material). It is opaque to the manager. + * + * @generated from message cryptos.fleet.v1.ExportCAKeyResponse + */ +export type ExportCAKeyResponse = Message<"cryptos.fleet.v1.ExportCAKeyResponse"> & { + /** + * envelope is the encrypted CA key backup. + * + * @generated from field: bytes envelope = 1; + */ + envelope: Uint8Array; +}; + +/** + * Describes the message cryptos.fleet.v1.ExportCAKeyResponse. + * Use `create(ExportCAKeyResponseSchema)` to create a new message. + */ +export const ExportCAKeyResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cryptos_fleet_v1_fleet, 51); + +/** + * ImportCAKeyRequest names the fresh target node, the encrypted envelope to + * restore, and the passphrase that unseals it. The passphrase is relayed to + * the node in transit and is never persisted by the manager. + * + * @generated from message cryptos.fleet.v1.ImportCAKeyRequest + */ +export type ImportCAKeyRequest = Message<"cryptos.fleet.v1.ImportCAKeyRequest"> & { + /** + * node_name is the fresh managed node to restore the identity onto. + * + * @generated from field: string node_name = 1; + */ + nodeName: string; + + /** + * envelope is the encrypted CA key backup produced by ExportCAKey. + * + * @generated from field: bytes envelope = 2; + */ + envelope: Uint8Array; + + /** + * passphrase unseals the envelope node-side; it is never persisted. + * + * @generated from field: bytes passphrase = 3; + */ + passphrase: Uint8Array; +}; + +/** + * Describes the message cryptos.fleet.v1.ImportCAKeyRequest. + * Use `create(ImportCAKeyRequestSchema)` to create a new message. + */ +export const ImportCAKeyRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cryptos_fleet_v1_fleet, 52); + +/** + * ImportCAKeyResponse summarizes the restored identity so the web can confirm + * what was imported, without returning the full identity chain. + * + * @generated from message cryptos.fleet.v1.ImportCAKeyResponse + */ +export type ImportCAKeyResponse = Message<"cryptos.fleet.v1.ImportCAKeyResponse"> & { + /** + * subject_cn is the restored identity's subject common name. + * + * @generated from field: string subject_cn = 1; + */ + subjectCn: string; + + /** + * issuer_cn is the restored identity's issuer common name. + * + * @generated from field: string issuer_cn = 2; + */ + issuerCn: string; +}; + +/** + * Describes the message cryptos.fleet.v1.ImportCAKeyResponse. + * Use `create(ImportCAKeyResponseSchema)` to create a new message. + */ +export const ImportCAKeyResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_cryptos_fleet_v1_fleet, 53); + /** * Health reports the manager's view of node reachability, independent of * the node's own reported identity state. @@ -1623,6 +1741,36 @@ export const FleetService: GenService<{ input: typeof ApplyNodeConfigRequestSchema; output: typeof ApplyNodeConfigResponseSchema; }, + /** + * ExportCAKey backs up a managed node's CA private key to an encrypted + * envelope. The node seals the backup with the operator passphrase + * (Argon2id + AES-256-GCM) so the plaintext key never leaves the node; the + * manager only relays the envelope through to the caller. The passphrase is + * used in transit and is never persisted. A TPM-backed node refuses export. + * Admin-gated and audited (the audit names the node only, never the secret). + * + * @generated from rpc cryptos.fleet.v1.FleetService.ExportCAKey + */ + exportCAKey: { + methodKind: "unary"; + input: typeof ExportCAKeyRequestSchema; + output: typeof ExportCAKeyResponseSchema; + }, + /** + * ImportCAKey restores a CA identity onto a fresh managed node from an + * encrypted envelope produced by ExportCAKey. The node decrypts the envelope + * with the operator passphrase and adopts the key; it refuses the import if + * it already holds an identity. The passphrase transits the manager only to + * reach the node and is never persisted. Admin-gated and audited (the audit + * names the node and restored subject only, never the secret or envelope). + * + * @generated from rpc cryptos.fleet.v1.FleetService.ImportCAKey + */ + importCAKey: { + methodKind: "unary"; + input: typeof ImportCAKeyRequestSchema; + output: typeof ImportCAKeyResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_cryptos_fleet_v1_fleet, 0); diff --git a/src/lib/escrow.test.ts b/src/lib/escrow.test.ts new file mode 100644 index 0000000..615019b --- /dev/null +++ b/src/lib/escrow.test.ts @@ -0,0 +1,104 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { exportCAKey, importCAKey } from "@/lib/escrow"; + +const exportCAKeyRpc = vi.fn(); +const importCAKeyRpc = vi.fn(); +vi.mock("@/lib/fleet/client", () => ({ + fleetClient: () => ({ + exportCAKey: (...args: unknown[]) => exportCAKeyRpc(...args), + importCAKey: (...args: unknown[]) => importCAKeyRpc(...args), + }), +})); + +let mode: "live" | "mock" = "live"; +vi.mock("@/lib/fleet/mode", () => ({ fleetMode: () => mode })); + +const STRONG = "correct-horse-battery-staple"; // >= 18 chars + +describe("escrow lib", () => { + beforeEach(() => { + mode = "live"; + exportCAKeyRpc.mockReset(); + importCAKeyRpc.mockReset(); + }); + + it("exportCAKey sends the passphrase bytes and returns the envelope (live)", async () => { + const envelope = new Uint8Array([1, 2, 3]); + exportCAKeyRpc.mockResolvedValue({ envelope }); + + const out = await exportCAKey("acme-root-01", STRONG); + + expect(out).toBe(envelope); + const arg = exportCAKeyRpc.mock.calls[0][0]; + expect(arg.nodeName).toBe("acme-root-01"); + expect(new TextDecoder().decode(arg.passphrase)).toBe(STRONG); + }); + + it("exportCAKey rejects a short passphrase before any call", async () => { + await expect(exportCAKey("acme-root-01", "too-short")).rejects.toThrow(/at least 18/); + expect(exportCAKeyRpc).not.toHaveBeenCalled(); + }); + + it("exportCAKey surfaces a live error (no silent fallback)", async () => { + exportCAKeyRpc.mockRejectedValue(new Error("node refused export")); + await expect(exportCAKey("acme-root-01", STRONG)).rejects.toThrow(/node refused export/); + }); + + it("exportCAKey returns a dummy envelope in mock mode without dialing", async () => { + mode = "mock"; + const out = await exportCAKey("acme-root-01", STRONG); + expect(out.length).toBeGreaterThan(0); + expect(exportCAKeyRpc).not.toHaveBeenCalled(); + }); + + it("importCAKey relays envelope and passphrase and returns the CN summary (live)", async () => { + importCAKeyRpc.mockResolvedValue({ issuerCn: "ACME Root CA", subjectCn: "ACME Sub CA" }); + const envelope = new Uint8Array([9, 8, 7]); + + const out = await importCAKey("acme-fresh-01", envelope, STRONG); + + expect(out).toEqual({ issuerCn: "ACME Root CA", subjectCn: "ACME Sub CA" }); + const arg = importCAKeyRpc.mock.calls[0][0]; + expect(arg.nodeName).toBe("acme-fresh-01"); + expect(arg.envelope).toBe(envelope); + expect(new TextDecoder().decode(arg.passphrase)).toBe(STRONG); + }); + + it("importCAKey rejects an empty envelope and a short passphrase before any call", async () => { + await expect(importCAKey("acme-fresh-01", new Uint8Array(), STRONG)).rejects.toThrow( + /envelope file is required/, + ); + await expect(importCAKey("acme-fresh-01", new Uint8Array([1]), "too-short")).rejects.toThrow( + /at least 18/, + ); + expect(importCAKeyRpc).not.toHaveBeenCalled(); + }); + + it("importCAKey surfaces the node's already-has-identity error", async () => { + importCAKeyRpc.mockRejectedValue( + new Error('node "acme-fresh-01" already has a CA identity; import only onto a fresh node'), + ); + await expect(importCAKey("acme-fresh-01", new Uint8Array([1]), STRONG)).rejects.toThrow( + /already has a CA identity/, + ); + }); +}); diff --git a/src/lib/escrow.ts b/src/lib/escrow.ts new file mode 100644 index 0000000..ce0b112 --- /dev/null +++ b/src/lib/escrow.ts @@ -0,0 +1,84 @@ +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { generateStrongPassphrase, MIN_PASSPHRASE_LENGTH } from "@/lib/crypto/leaf-key"; +import { fleetClient } from "@/lib/fleet/client"; +import { fleetMode } from "@/lib/fleet/mode"; + +// Re-export the shared passphrase helpers so escrow callers have one import. +// The floor (>= 18) and the generator (crypto.getRandomValues) are the same +// ones the leaf-key export uses, keeping every secret-entry surface consistent. +export { generateStrongPassphrase, MIN_PASSPHRASE_LENGTH }; + +// RestoredIdentity is the CN summary the manager returns after an import, so +// the UI can confirm what was restored without handling the full chain. +export interface RestoredIdentity { + issuerCn: string; + subjectCn: string; +} + +// exportCAKey asks the manager to relay a node's encrypted CA-key backup. The +// passphrase seals the backup node-side; the plaintext key never leaves the +// node, and this function returns only the opaque encrypted envelope for the +// caller to download. In `mock` mode it returns a dummy envelope so the flow is +// exercisable offline; in live mode a manager/node error surfaces to the caller +// (no silent fallback). The passphrase is validated (>= MIN_PASSPHRASE_LENGTH) +// here too, matching the manager's server-side guard. +export const exportCAKey = async (nodeName: string, passphrase: string): Promise => { + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`); + } + + if (fleetMode() === "mock") { + return new TextEncoder().encode(`mock-encrypted-envelope-for-${nodeName}`); + } + + const response = await fleetClient().exportCAKey({ + nodeName, + passphrase: new TextEncoder().encode(passphrase), + }); + return response.envelope; +}; + +// importCAKey relays an encrypted envelope and its passphrase to a fresh node +// through the manager, returning the restored identity's CN summary. In `mock` +// mode it returns a canned summary; in live mode a manager/node error (for +// example the target already holding an identity) surfaces to the caller. +export const importCAKey = async ( + nodeName: string, + envelope: Uint8Array, + passphrase: string, +): Promise => { + if (envelope.length === 0) { + throw new Error("A backup envelope file is required."); + } + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`); + } + + if (fleetMode() === "mock") { + return { issuerCn: `Restored issuer for ${nodeName}`, subjectCn: `Restored CA on ${nodeName}` }; + } + + const response = await fleetClient().importCAKey({ + envelope, + nodeName, + passphrase: new TextEncoder().encode(passphrase), + }); + return { issuerCn: response.issuerCn, subjectCn: response.subjectCn }; +};