Skip to content
Merged
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
94 changes: 94 additions & 0 deletions src-tauri/src/commands/keygen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,38 @@ pub struct GeneratedKeyPair {
pub key_type_label: String,
}

/// The public half is missing whenever a key was imported private-only, so the
/// callers need to tell "give me the passphrase" apart from "that is not a key".
/// Codes, not prose: the UI branches on them and translates its own message.
pub const ERR_ENCRYPTED: &str = "ENCRYPTED";
pub const ERR_INVALID: &str = "INVALID";

fn derive_public_key(private_key: &str, passphrase: Option<&str>) -> Result<String, String> {
let key = PrivateKey::from_openssh(private_key.trim()).map_err(|_| ERR_INVALID.to_string())?;
let key = if key.is_encrypted() {
let passphrase = passphrase
.filter(|p| !p.is_empty())
.ok_or_else(|| ERR_ENCRYPTED.to_string())?;
key.decrypt(passphrase)
.map_err(|_| ERR_ENCRYPTED.to_string())?
} else {
key
};
key.public_key()
.to_openssh()
.map_err(|_| ERR_INVALID.to_string())
}

#[tauri::command]
pub async fn ssh_public_key_from_private(
private_key: String,
passphrase: Option<String>,
) -> Result<String, String> {
spawn_blocking(move || derive_public_key(&private_key, passphrase.as_deref()))
.await
.map_err(|e| e.to_string())?
}

/// key_type: "ed25519" | "ecdsa" | "rsa"
/// curve: "256" | "384" | "521" (ecdsa only)
/// bits: 2048 | 4096 (rsa only)
Expand Down Expand Up @@ -104,3 +136,65 @@ pub async fn generate_ssh_keypair(
.await
.map_err(|e| e.to_string())?
}

#[cfg(test)]
mod tests {
use super::*;

fn ed25519_pem(passphrase: Option<&str>) -> (String, String) {
let mut rng = rand::thread_rng();
let key = PrivateKey::random(&mut rng, Algorithm::Ed25519).unwrap();
let public = key.public_key().to_openssh().unwrap();
let pem = match passphrase {
Some(p) => key
.encrypt(&mut rng, p)
.unwrap()
.to_openssh(LineEnding::LF)
.unwrap(),
None => key.to_openssh(LineEnding::LF).unwrap(),
};
(pem.to_string(), public)
}

#[test]
fn derives_the_public_half_of_a_plaintext_key() {
let (pem, public) = ed25519_pem(None);
assert_eq!(derive_public_key(&pem, None).unwrap(), public);
}

#[test]
fn derives_the_public_half_of_an_encrypted_key() {
let (pem, public) = ed25519_pem(Some("hunter2"));
assert_eq!(derive_public_key(&pem, Some("hunter2")).unwrap(), public);
}

#[test]
fn reports_encrypted_when_the_passphrase_is_missing_or_wrong() {
let (pem, _) = ed25519_pem(Some("hunter2"));
assert_eq!(
derive_public_key(&pem, None),
Err(ERR_ENCRYPTED.to_string())
);
assert_eq!(
derive_public_key(&pem, Some("")),
Err(ERR_ENCRYPTED.to_string())
);
assert_eq!(
derive_public_key(&pem, Some("wrong")),
Err(ERR_ENCRYPTED.to_string())
);
}

#[test]
fn reports_invalid_for_anything_that_is_not_a_private_key() {
assert_eq!(
derive_public_key("nonsense", None),
Err(ERR_INVALID.to_string())
);
let (_, public) = ed25519_pem(None);
assert_eq!(
derive_public_key(&public, None),
Err(ERR_INVALID.to_string())
);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ pub fn run() {
commands::keys::key_adopt,
commands::keys::key_delete,
commands::keygen::generate_ssh_keypair,
commands::keygen::ssh_public_key_from_private,
commands::vault::vault_status,
commands::vault::vault_reset,
commands::vault::config_wipe,
Expand Down
7 changes: 7 additions & 0 deletions src/components/keychain/IdentityForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useIdentityStore } from "@/stores/identityStore";
import { useTeamStore } from "@/stores/teamStore";
import { KeyFileDropZone } from "./KeyForm";
import { PublicKeyField, isPublicKeyInvalid } from "./PublicKeyField";
import { useDerivedPublicKey } from "./useDerivedPublicKey";
import { getConnectionIcon, getConnectionIconColor } from "@/utils/icons";
import { AvatarTile } from "@/components/shared/AvatarTile";
import type { AuthType, Connection, Identity, IdentityFormData } from "@/types";
Expand Down Expand Up @@ -287,6 +288,12 @@ export function IdentityForm({ initial, onSubmit, onClose, onDelete, flushRef, i
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => schedule(), [name, tags, username, password, keyId, folderId, vaultId, inlineKeyLabel, inlinePrivKey, inlinePublicKey]);

useDerivedPublicKey({
privateKey: inlinePrivKey,
publicKey: inlinePublicKey,
onDerived: (derived) => { markDirty(); setInlinePublicKey(derived); },
});

const handleClose = () => flushAndClose(onClose);

const handleTogglePassword = useCallback(() => {
Expand Down
97 changes: 97 additions & 0 deletions src/components/keychain/KeyExportPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen } from "@testing-library/react";
import type { SshKey } from "@/types";

const h = vi.hoisted(() => ({
ensurePublicKey: vi.fn(async (..._a: unknown[]) => "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me@laptop" as string | null),
addKeyToHost: vi.fn(async (..._a: unknown[]) => {}),
addToast: vi.fn(),
connections: [{ id: "c1", name: "prod", host: "example.test", port: 22, username: "root" }],
}));

vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
vi.mock("@/services/publicKeyStore", () => ({ ensurePublicKey: (...a: unknown[]) => h.ensurePublicKey(...a) }));
vi.mock("@/services/keyExport", () => ({
addKeyToHost: (...a: unknown[]) => h.addKeyToHost(...a),
DEFAULT_EXPORT_SCRIPT: "script",
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ connections: h.connections, loadConnections: vi.fn(async () => {}) }),
}));
vi.mock("@/stores/notificationStore", () => ({
useNotificationStore: { getState: () => ({ addToast: h.addToast }) },
}));
vi.mock("./KeyCards", () => ({ KeyCardContent: () => null }));
vi.mock("@/components/shared/HostPickerPanel", () => ({
HostPickerPanel: ({ onPick }: { onPick: (h: unknown) => void }) => (
<button data-pick-host onClick={() => onPick({ kind: "remote", connection: h.connections[0] })} />
),
}));

const { KeyExportPanel } = await import("./KeyExportPanel");

const sshKey = { id: "k1", name: "laptop", vault_id: "personal" } as SshKey;

function renderPanel() {
const onClose = vi.fn();
render(<KeyExportPanel sshKey={sshKey} onClose={onClose} />);
return { onClose };
}

const footer = () => document.querySelector("[data-export-footer]") as HTMLElement;
const exportButton = () => footer().querySelector("button") as HTMLButtonElement;

async function pickHost() {
await act(async () => {
fireEvent.click(document.querySelector("[data-pick-host]")!);
});
}

beforeEach(() => {
vi.clearAllMocks();
h.ensurePublicKey.mockResolvedValue("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me@laptop");
h.addKeyToHost.mockResolvedValue(undefined);
});
afterEach(() => cleanup());

test("refuses the deploy up front when the key has no public half to send", async () => {
h.ensurePublicKey.mockResolvedValue(null);
renderPanel();
await act(async () => { await Promise.resolve(); });
await pickHost();
// The old failure mode was a host picked, a button clicked, and only then an
// error — with a key whose public half could never have been found.
expect(screen.getByText("keychain.exportPanel.missingPublicKeyNotice")).toBeTruthy();
expect(exportButton().disabled).toBe(true);
});

test("deploys once a host is picked and the public half is in hand", async () => {
renderPanel();
await act(async () => { await Promise.resolve(); });
expect(exportButton().disabled).toBe(true);
await pickHost();
expect(exportButton().disabled).toBe(false);
await act(async () => { fireEvent.click(exportButton()); });
expect(h.addKeyToHost).toHaveBeenCalledWith(expect.objectContaining({ sshKey, connection: h.connections[0] }));
});

test("keeps a failure pinned beside the button instead of below the fold", async () => {
h.addKeyToHost.mockRejectedValue(new Error("Remote command failed: permission denied"));
renderPanel();
await act(async () => { await Promise.resolve(); });
await pickHost();
await act(async () => { fireEvent.click(exportButton()); });
expect(footer().contains(screen.getByText(/permission denied/))).toBe(true);
});

test("reports success as a toast and closes the panel", async () => {
const { onClose } = renderPanel();
await act(async () => { await Promise.resolve(); });
await pickHost();
await act(async () => { fireEvent.click(exportButton()); });
expect(h.addToast).toHaveBeenCalledWith(
expect.objectContaining({ severity: "success", message: "keychain.exportPanel.successMessage" }),
);
expect(onClose).toHaveBeenCalled();
});
66 changes: 47 additions & 19 deletions src/components/keychain/KeyExportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Icon } from "@iconify/react";
import { useRipple } from "@/hooks/useRipple";
import { useConnectionStore } from "@/stores/connectionStore";
import { addKeyToHost, DEFAULT_EXPORT_SCRIPT } from "@/services/keyExport";
import { ensurePublicKey } from "@/services/publicKeyStore";
import { useNotificationStore } from "@/stores/notificationStore";
import { statusSurface } from "@/components/shared/statusSurface";
import {
PanelShell, PanelHeader, FormSection,
formInputClass, formInputStyle, formLabelClass, formLabelStyle,
Expand Down Expand Up @@ -45,21 +48,44 @@ export function KeyExportPanel({ sshKey, onClose }: { sshKey: SshKey; onClose: (
const [script, setScript] = useState(DEFAULT_EXPORT_SCRIPT);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [showHostSelect, setShowHostSelect] = useState(false);
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "error">("idle");
const [exportError, setExportError] = useState("");
const [publicHalf, setPublicHalf] = useState<"checking" | "ready" | "missing">("checking");
const { createRipple: rippleExport, rippleEls: ripplesExport } = useRipple();

useEffect(() => { void loadConnections(); }, [loadConnections]);

// Asked before a host is even picked: a key imported private-only has nothing
// to deploy, and finding that out after choosing a host and pressing the
// button is what made the failure read like a broken feature.
useEffect(() => {
let cancelled = false;
setPublicHalf("checking");
void ensurePublicKey(sshKey).then((pub) => {
if (!cancelled) setPublicHalf(pub ? "ready" : "missing");
});
return () => { cancelled = true; };
}, [sshKey]);

const selectedHost = connections.find((c) => c.id === selectedHostId);
const canExport = !!selectedHost && publicHalf === "ready" && exportStatus !== "loading";

const handleExport = async () => {
if (!selectedHost) return;
setExportStatus("loading");
setExportError("");
try {
await addKeyToHost({ sshKey, connection: selectedHost, location, filename, script });
setExportStatus("success");
// Said where the eye already is rather than in a panel that is closing.
useNotificationStore.getState().addToast({
source: { kind: "plugin", id: "system", name: "Voltius" },
type: "toast",
message: t("keychain.exportPanel.successMessage"),
severity: "success",
duration: 4000,
});
setExportStatus("idle");
onClose();
} catch (e) {
setExportError(String(e));
setExportStatus("error");
Expand All @@ -76,6 +102,13 @@ export function KeyExportPanel({ sshKey, onClose }: { sshKey: SshKey; onClose: (
<KeyCardContent sshKey={sshKey} avatarSize={48} iconSize={24} />
</BaseCard>

{publicHalf === "missing" && (
<div className="flex gap-2 px-3 py-2.5 rounded-lg mx-1 text-xs" style={statusSurface("warning")}>
<Icon icon="lucide:triangle-alert" width={14} className="shrink-0" style={{ marginTop: 1 }} />
<p className="leading-relaxed">{t("keychain.exportPanel.missingPublicKeyNotice")}</p>
</div>
)}

<FormSection label={t("keychain.exportPanel.exportSectionLabel")}>
<div className="space-y-3 p-1">
<div>
Expand Down Expand Up @@ -140,30 +173,25 @@ export function KeyExportPanel({ sshKey, onClose }: { sshKey: SshKey; onClose: (
</div>
</FormSection>

{exportStatus === "success" && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg mx-1" style={{ background: "color-mix(in srgb, var(--t-accent) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--t-accent) 30%, transparent)" }}>
<Icon icon="lucide:circle-check-big" width={14} className="text-(--t-accent) shrink-0" />
<span className="text-xs text-(--t-accent)">{t("keychain.exportPanel.successMessage")}</span>
</div>
)}
</div>

{/* The outcome belongs against the button that caused it: in the scroll
area it landed below the fold, so a click looked like it did nothing. */}
<div className="px-4 py-3 border-t border-t-(--t-border) space-y-2" data-export-footer>
{exportStatus === "error" && (
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg mx-1" style={{ background: "color-mix(in srgb, var(--t-danger, #ef4444) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--t-danger, #ef4444) 30%, transparent)" }}>
<Icon icon="lucide:circle-x" width={14} className="text-(--t-danger,#ef4444) shrink-0" style={{ marginTop: 1 }} />
<span className="text-xs break-all text-(--t-danger,#ef4444)">{exportError}</span>
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg max-h-20 overflow-y-auto" style={statusSurface("error")}>
<Icon icon="lucide:circle-x" width={14} className="shrink-0" style={{ marginTop: 1 }} />
<span className="text-xs break-all">{exportError}</span>
</div>
)}

</div>

<div className="px-4 py-3 border-t border-t-(--t-border)">
<button
onClick={() => { void handleExport(); }}
onPointerDown={(!selectedHostId || exportStatus === "loading") ? undefined : rippleExport}
disabled={!selectedHostId || exportStatus === "loading"}
onPointerDown={canExport ? rippleExport : undefined}
disabled={!canExport}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity bg-(--t-accent) text-white relative overflow-hidden"
style={{
opacity: !selectedHostId || exportStatus === "loading" ? 0.5 : 1,
cursor: !selectedHostId || exportStatus === "loading" ? "not-allowed" : "pointer",
opacity: canExport ? 1 : 0.5,
cursor: canExport ? "pointer" : "not-allowed",
}}
>
{ripplesExport}
Expand Down
13 changes: 13 additions & 0 deletions src/components/keychain/KeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { detectKeyInfo } from "./keyDetection";
import { KeyFileDropZone } from "./KeyFileDropZone";
import { KeyGenFields } from "./KeyGenFields";
import { PublicKeyField, isPublicKeyInvalid } from "./PublicKeyField";
import { useDerivedPublicKey } from "./useDerivedPublicKey";

// Re-exported for back-compat (IdentityForm imports KeyFileDropZone from here).
export { KeyFileDropZone } from "./KeyFileDropZone";
Expand Down Expand Up @@ -141,6 +142,18 @@ export function KeyForm({ initial, initialMode, onSubmit, onClose, onExport, onD
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => schedule(), [name, tags, privateKey, publicKey, passphrase, folderId, vaultId]);

useDerivedPublicKey({
privateKey,
publicKey,
passphrase,
enabled: !publicKeyDirty.current,
onDerived: (derived) => {
markDirty();
publicKeyDirty.current = true;
setPublicKey(derived);
},
});

const handleClose = () => flushAndClose(onClose);

// Generated material flows in here; reveal it in the import view and let
Expand Down
Loading