From 5e855fcadb7e91cf2ddbff066cc7743c41f92939 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 22 Aug 2026 17:35:06 +0000 Subject: [PATCH] fix(keychain): derive a key's public half instead of refusing to deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add to host" read `key::public` and threw "Public key not found" when nothing was stored. The public half is optional at import, and nothing ever derived it, so a key pasted or dropped private-only saved without complaint and refused to deploy — often weeks later. A new backend command computes the public half from the private one (`ssh_public_key_from_private`, error codes ENCRYPTED / INVALID so the UI can tell "needs a passphrase" from "not a key"). `ensurePublicKey` reads the stored half, else derives from the private half and the saved passphrase, validates it, and backfills both the local vault and the key's team vault. - KeyForm and IdentityForm's inline key material fill the visible public field as soon as a complete private half is entered, so the value can be read and corrected rather than conjured at deploy time. - addKeyToHost goes through the store, which fixes the plugin/MCP route too. - The export panel asks before a host is picked: a key with no derivable public half shows a warning and a disabled button, instead of failing after the host was chosen. - The outcome no longer renders at the bottom of the scroll area, where a click looked like it did nothing: failures pin into the sticky footer above the button, and success raises a toast and closes the panel. --- src-tauri/src/commands/keygen.rs | 94 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/keychain/IdentityForm.tsx | 7 ++ .../keychain/KeyExportPanel.test.tsx | 97 +++++++++++++++++++ src/components/keychain/KeyExportPanel.tsx | 66 +++++++++---- src/components/keychain/KeyForm.tsx | 13 +++ .../keychainForms.characterisation.test.tsx | 61 ++++++++++++ .../keychain/useDerivedPublicKey.ts | 48 +++++++++ .../sheets/KeychainItemActionsSheet.tsx | 4 +- src/i18n/locales/en/keychain.json | 1 + src/i18n/locales/fr/keychain.json | 1 + src/i18n/locales/ru/keychain.json | 1 + src/i18n/locales/zh/keychain.json | 1 + src/services/keyExport.test.ts | 19 ++-- src/services/keyExport.ts | 4 +- src/services/publicKeyStore.test.ts | 89 +++++++++++++++++ src/services/publicKeyStore.ts | 56 +++++++++++ 17 files changed, 531 insertions(+), 32 deletions(-) create mode 100644 src/components/keychain/KeyExportPanel.test.tsx create mode 100644 src/components/keychain/useDerivedPublicKey.ts create mode 100644 src/services/publicKeyStore.test.ts create mode 100644 src/services/publicKeyStore.ts diff --git a/src-tauri/src/commands/keygen.rs b/src-tauri/src/commands/keygen.rs index 66a980a3b..fe8ad0a1f 100644 --- a/src-tauri/src/commands/keygen.rs +++ b/src-tauri/src/commands/keygen.rs @@ -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 { + 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, +) -> Result { + 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) @@ -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()) + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index befbb3ad5..ca230c852 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/keychain/IdentityForm.tsx b/src/components/keychain/IdentityForm.tsx index 7ef3cb0b9..2eea90f45 100644 --- a/src/components/keychain/IdentityForm.tsx +++ b/src/components/keychain/IdentityForm.tsx @@ -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"; @@ -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(() => { diff --git a/src/components/keychain/KeyExportPanel.test.tsx b/src/components/keychain/KeyExportPanel.test.tsx new file mode 100644 index 000000000..a520f0188 --- /dev/null +++ b/src/components/keychain/KeyExportPanel.test.tsx @@ -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 }) => ( +