From 268e4fbeaefc6de83d587ade5afc64a6b8dec435 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Mon, 11 Aug 2025 11:26:09 -0400 Subject: [PATCH] feat(electron): added key managment ui --- build-python/meson.build | 3 +- electron/bridge.py | 25 +- electron/index.html | 137 ++++ electron/installer-resources/welcome.html | 2 +- electron/main-refactored.js | 5 +- electron/main/backend-bridge.js | 11 +- electron/main/file-dialogs.js | 17 + electron/main/ipc-handlers.js | 66 +- electron/renderer-refactored.js | 4 + electron/renderer/dom-manager.js | 6 +- electron/renderer/event-handlers.js | 106 ++- electron/renderer/key-operations.js | 509 +++++++++++++++ electron/renderer/state-manager.js | 32 + electron/renderer/ui-components.js | 11 +- electron/styles.css | 705 ++++++++++++++++++++ electron/~/Desktop/test.pem | 3 + electron/~/Desktop/test.pub | 1 + electron/~/Desktop/test.pub.pem | 3 + fourdst/cli/keys/add.py | 25 +- fourdst/cli/keys/generate.py | 66 +- fourdst/cli/keys/list.py | 34 +- fourdst/cli/keys/remote/add.py | 25 +- fourdst/cli/keys/remote/list.py | 30 +- fourdst/cli/keys/remote/remove.py | 29 +- fourdst/cli/keys/remove.py | 76 +-- fourdst/cli/keys/sync.py | 86 ++- fourdst/core/build.py | 9 +- fourdst/core/keys.py | 746 ++++++++++++++++++++++ 28 files changed, 2525 insertions(+), 247 deletions(-) create mode 100644 electron/renderer/key-operations.js create mode 100644 electron/~/Desktop/test.pem create mode 100644 electron/~/Desktop/test.pub create mode 100644 electron/~/Desktop/test.pub.pem create mode 100644 fourdst/core/keys.py diff --git a/build-python/meson.build b/build-python/meson.build index c9cc470..3ebe8ee 100644 --- a/build-python/meson.build +++ b/build-python/meson.build @@ -105,7 +105,8 @@ py_installation.install_sources( meson.project_source_root() + '/fourdst/core/bundle.py', meson.project_source_root() + '/fourdst/core/config.py', meson.project_source_root() + '/fourdst/core/platform.py', - meson.project_source_root() + '/fourdst/core/utils.py' + meson.project_source_root() + '/fourdst/core/utils.py', + meson.project_source_root() + '/fourdst/core/keys.py', ), subdir: 'fourdst/core' ) diff --git a/electron/bridge.py b/electron/bridge.py index e93934c..1652629 100644 --- a/electron/bridge.py +++ b/electron/bridge.py @@ -11,11 +11,10 @@ that now return JSON directly. No more complex stdout mixing or data wrapping. Key Changes: - Core functions return JSON-serializable dictionaries directly - Progress messages go to stderr only (never mixed with JSON output) -- Clean JSON output to stdout for Electron to parse -- Simplified error handling with consistent JSON error format """ import sys +import os import json import inspect import traceback @@ -35,7 +34,7 @@ class FourdstEncoder(json.JSONEncoder): project_root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(project_root)) -from fourdst.core import bundle +from fourdst.core import bundle, keys def main(): # Use stderr for all logging to avoid interfering with JSON output on stdout @@ -62,13 +61,25 @@ def main(): print(f"[BRIDGE_INFO] Parsed kwargs: {kwargs}", file=log_file, flush=True) # Convert path strings to Path objects where needed + path_params = ['outputDir', 'output_dir', 'keyPath', 'key_path', 'bundlePath', 'bundle_path', 'path'] for key, value in kwargs.items(): - if isinstance(value, str) and ('path' in key.lower() or 'key' in key.lower()): + if isinstance(value, str) and key in path_params: kwargs[key] = Path(value) elif isinstance(value, list) and 'dirs' in key.lower(): kwargs[key] = [Path(p) for p in value] - func = getattr(bundle, command) + # Route commands to appropriate modules + key_commands = [ + 'list_keys', 'generate_key', 'add_key', 'remove_key', + 'sync_remotes', 'get_remote_sources', 'add_remote_source', 'remove_remote_source' + ] + + if command in key_commands: + func = getattr(keys, command) + module_name = "keys" + else: + func = getattr(bundle, command) + module_name = "bundle" # Create progress callback that sends structured progress to stderr # This keeps progress separate from the final JSON result on stdout @@ -87,7 +98,9 @@ def main(): if 'progress_callback' in sig.parameters: kwargs['progress_callback'] = progress_callback - print(f"[BRIDGE_INFO] Calling function `bundle.{command}`...", file=log_file, flush=True) + print(f"[BRIDGE_INFO] Calling function `{module_name}.{command}`...", file=log_file, flush=True) + print(f"[BRIDGE_DEBUG] Function signature: {func.__name__}{inspect.signature(func)}", file=log_file, flush=True) + print(f"[BRIDGE_DEBUG] Kwargs being passed: {kwargs}", file=log_file, flush=True) result = func(**kwargs) print(f"[BRIDGE_INFO] Function returned successfully.", file=log_file, flush=True) diff --git a/electron/index.html b/electron/index.html index 3db0b7b..2517be9 100644 --- a/electron/index.html +++ b/electron/index.html @@ -30,6 +30,10 @@
LC
libconstants +
+
KM
+ Key Management +
OC
OPAT Core @@ -71,6 +75,19 @@
+ + + +
+
KM
+
+

Key Management

+

Manage cryptographic keys for bundle signing and verification.

+
+
+
LC
@@ -455,6 +480,118 @@
+ + + diff --git a/electron/installer-resources/welcome.html b/electron/installer-resources/welcome.html index 665f0fe..931f9ea 100644 --- a/electron/installer-resources/welcome.html +++ b/electron/installer-resources/welcome.html @@ -60,7 +60,7 @@

This installer will install the 4DSTAR Bundle Manager, a comprehensive tool for managing 4DSTAR plugin bundles and OPAT data files.

-
+
What's Included:
  • 4DSTAR Bundle Manager application
  • diff --git a/electron/main-refactored.js b/electron/main-refactored.js index ee6c2a5..4cbabfd 100644 --- a/electron/main-refactored.js +++ b/electron/main-refactored.js @@ -5,7 +5,7 @@ // Import modular components const { setupAppEventHandlers, setupThemeHandlers } = require('./main/app-lifecycle'); const { setupFileDialogHandlers } = require('./main/file-dialogs'); -const { setupBundleIPCHandlers } = require('./main/ipc-handlers'); +const { setupBundleIPCHandlers, setupKeyIPCHandlers } = require('./main/ipc-handlers'); // Initialize all modules in the correct order function initializeMainProcess() { @@ -21,6 +21,9 @@ function initializeMainProcess() { // Setup bundle operation IPC handlers setupBundleIPCHandlers(); + // Setup key management IPC handlers + setupKeyIPCHandlers(); + console.log('[MAIN_PROCESS] All modules initialized successfully'); } diff --git a/electron/main/backend-bridge.js b/electron/main/backend-bridge.js index ace5a6e..ab5fa77 100644 --- a/electron/main/backend-bridge.js +++ b/electron/main/backend-bridge.js @@ -10,16 +10,21 @@ function runPythonCommand(command, kwargs, event) { // Determine executable name based on platform const executableName = process.platform === 'win32' ? 'fourdst-backend.exe' : 'fourdst-backend'; + // Initialize args first + let args = [command, JSON.stringify(kwargs)]; + if (app.isPackaged) { // In packaged app, backend is in resources/backend/ directory backendPath = path.join(process.resourcesPath, 'backend', executableName); } else { - // In development, use the meson build output - backendPath = path.join(buildDir, 'electron', 'dist', 'fourdst-backend', executableName); + // In development, use the Python bridge.py directly for faster iteration + backendPath = 'python'; + // Update args to include the bridge.py path as the first argument + const bridgePath = path.join(__dirname, '..', 'bridge.py'); + args.unshift(bridgePath); } console.log(`[MAIN_PROCESS] Spawning backend: ${backendPath}`); - const args = [command, JSON.stringify(kwargs)]; console.log(`[MAIN_PROCESS] With args: [${args.join(', ')}]`); return new Promise((resolve) => { diff --git a/electron/main/file-dialogs.js b/electron/main/file-dialogs.js index 5e3c0ec..b3be9bf 100644 --- a/electron/main/file-dialogs.js +++ b/electron/main/file-dialogs.js @@ -29,6 +29,23 @@ const setupFileDialogHandlers = () => { return null; }); + // Key file selection dialog + ipcMain.handle('select-key-file', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Select Public Key File', + filters: [ + { name: 'Public Key Files', extensions: ['pub', 'pem'] }, + { name: 'All Files', extensions: ['*'] } + ] + }); + + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0]; + } + return null; + }); + // Save file dialog ipcMain.handle('select-save-file', async () => { const result = await dialog.showSaveDialog({ diff --git a/electron/main/ipc-handlers.js b/electron/main/ipc-handlers.js index 6be87a7..7fc67fb 100644 --- a/electron/main/ipc-handlers.js +++ b/electron/main/ipc-handlers.js @@ -3,6 +3,69 @@ const { runPythonCommand } = require('./backend-bridge'); const fs = require('fs-extra'); const path = require('path'); +const setupKeyIPCHandlers = () => { + // List keys handler + ipcMain.handle('list-keys', async (event) => { + const kwargs = {}; + return runPythonCommand('list_keys', kwargs, event); + }); + + // Generate key handler + ipcMain.handle('generate-key', async (event, { keyName, keyType, outputDir }) => { + const kwargs = { + key_name: keyName, + key_type: keyType, + output_dir: outputDir + }; + return runPythonCommand('generate_key', kwargs, event); + }); + + // Add key handler + ipcMain.handle('add-key', async (event, keyPath) => { + const kwargs = { + key_path: keyPath + }; + return runPythonCommand('add_key', kwargs, event); + }); + + // Remove key handler + ipcMain.handle('remove-key', async (event, keyIdentifier) => { + const kwargs = { + key_identifier: keyIdentifier + }; + return runPythonCommand('remove_key', kwargs, event); + }); + + // Sync remotes handler + ipcMain.handle('sync-remotes', async (event) => { + const kwargs = {}; + return runPythonCommand('sync_remotes', kwargs, event); + }); + + // Get remote sources handler + ipcMain.handle('get-remote-sources', async (event) => { + const kwargs = {}; + return runPythonCommand('get_remote_sources', kwargs, event); + }); + + // Add remote source handler + ipcMain.handle('add-remote-source', async (event, { name, url }) => { + const kwargs = { + name: name, + url: url + }; + return runPythonCommand('add_remote_source', kwargs, event); + }); + + // Remove remote source handler + ipcMain.handle('remove-remote-source', async (event, name) => { + const kwargs = { + name: name + }; + return runPythonCommand('remove_remote_source', kwargs, event); + }); +}; + const setupBundleIPCHandlers = () => { // Create bundle handler ipcMain.handle('create-bundle', async (event, bundleData) => { @@ -171,5 +234,6 @@ const setupBundleIPCHandlers = () => { }; module.exports = { - setupBundleIPCHandlers + setupBundleIPCHandlers, + setupKeyIPCHandlers }; diff --git a/electron/renderer-refactored.js b/electron/renderer-refactored.js index 3c685d8..4da0d65 100644 --- a/electron/renderer-refactored.js +++ b/electron/renderer-refactored.js @@ -8,6 +8,7 @@ const { ipcRenderer } = require('electron'); const stateManager = require('./renderer/state-manager'); const domManager = require('./renderer/dom-manager'); const bundleOperations = require('./renderer/bundle-operations'); +const keyOperations = require('./renderer/key-operations'); const uiComponents = require('./renderer/ui-components'); const eventHandlers = require('./renderer/event-handlers'); const opatHandler = require('./renderer/opat-handler'); @@ -21,6 +22,7 @@ function initializeModules() { stateManager, domManager, bundleOperations, + keyOperations, uiComponents, eventHandlers, opatHandler, @@ -30,6 +32,7 @@ function initializeModules() { // Initialize each module with its dependencies bundleOperations.initializeDependencies(deps); + keyOperations.initializeDependencies(deps); uiComponents.initializeDependencies(deps); eventHandlers.initializeDependencies(deps); opatHandler.initializeDependencies(deps); @@ -83,6 +86,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.stateManager = stateManager; window.domManager = domManager; window.bundleOperations = bundleOperations; +window.keyOperations = keyOperations; window.uiComponents = uiComponents; window.eventHandlers = eventHandlers; window.opatHandler = opatHandler; diff --git a/electron/renderer/dom-manager.js b/electron/renderer/dom-manager.js index 6c2a1c1..835916a 100644 --- a/electron/renderer/dom-manager.js +++ b/electron/renderer/dom-manager.js @@ -2,7 +2,7 @@ // Extracted from renderer.js to centralize DOM element handling and view management // --- DOM ELEMENTS (will be initialized in initializeDOMElements) --- -let welcomeScreen, bundleView, createBundleForm; +let welcomeScreen, bundleView, keysView, createBundleForm; let openBundleBtn, createBundleBtn; let signBundleBtn, validateBundleBtn, clearBundleBtn, saveMetadataBtn; let saveOptionsModal, overwriteBundleBtn, saveAsNewBtn; @@ -43,6 +43,7 @@ function initializeDOMElements() { // Views welcomeScreen = document.getElementById('welcome-screen'); bundleView = document.getElementById('bundle-view'); + keysView = document.getElementById('keys-view'); createBundleForm = document.getElementById('create-bundle-form'); // Sidebar buttons @@ -89,7 +90,7 @@ function showView(viewId) { const opatView = document.getElementById('opat-view'); // Hide main content views - [welcomeScreen, bundleView, createBundleForm].forEach(view => { + [welcomeScreen, bundleView, keysView, createBundleForm].forEach(view => { view.classList.toggle('hidden', view.id !== viewId); }); @@ -171,6 +172,7 @@ module.exports = { getElements: () => ({ welcomeScreen, bundleView, + keysView, createBundleForm, openBundleBtn, createBundleBtn, diff --git a/electron/renderer/event-handlers.js b/electron/renderer/event-handlers.js index 2a26811..9c7d5da 100644 --- a/electron/renderer/event-handlers.js +++ b/electron/renderer/event-handlers.js @@ -4,7 +4,7 @@ const { ipcRenderer } = require('electron'); // Import dependencies (these will be injected when integrated) -let stateManager, domManager, bundleOperations, fillWorkflow, uiComponents, opatHandler; +let stateManager, domManager, bundleOperations, keyOperations, fillWorkflow, uiComponents, opatHandler; // --- EVENT LISTENERS SETUP --- function setupEventListeners() { @@ -86,6 +86,9 @@ function setupEventListeners() { elements.overwriteBundleBtn.addEventListener('click', () => handleSaveMetadata(false)); elements.saveAsNewBtn.addEventListener('click', () => handleSaveMetadata(true)); + // Key Management event listeners + setupKeyManagementEventListeners(); + // Signature warning modal event listeners elements.signatureWarningCancel.addEventListener('click', () => { elements.signatureWarningModal.classList.add('hidden'); @@ -207,7 +210,7 @@ function showCategoryHomeScreen(category) { const views = [ 'welcome-screen', 'libplugin-home', 'opat-home', 'libconstants-home', 'serif-home', 'opat-view', 'libplugin-view', - 'bundle-view', 'create-bundle-form' + 'bundle-view', 'keys-view', 'create-bundle-form' ]; // Hide all views @@ -222,12 +225,28 @@ function showCategoryHomeScreen(category) { 'libplugin': 'libplugin-home', 'opat': 'opat-home', 'libconstants': 'libconstants-home', - 'serif': 'serif-home' + 'serif': 'serif-home', + 'keys': 'keys-view' }; const viewId = viewMap[category] || 'welcome-screen'; const view = document.getElementById(viewId); - if (view) view.classList.remove('hidden'); + if (view) { + view.classList.remove('hidden'); + + // Initialize key management when showing keys view + if (category === 'keys' && keyOperations) { + // Show the default keys list view and load keys + showKeyManagementView('keys-list-view'); + keyOperations.loadTrustedKeys(); + + // Set the keys-list-btn as active in sidebar + const keysSidebarButtons = document.querySelectorAll('#keys-list-btn, #keys-generate-btn, #keys-add-btn, #keys-remotes-btn'); + keysSidebarButtons.forEach(btn => btn.classList.remove('active')); + const keysListBtn = document.getElementById('keys-list-btn'); + if (keysListBtn) keysListBtn.classList.add('active'); + } + } } // Setup info modal @@ -411,11 +430,88 @@ async function handleSaveMetadata(saveAsNew = false) { uiComponents.hideSaveOptionsModal(); } +// Setup key management event listeners +function setupKeyManagementEventListeners() { + // Key management sidebar navigation + const keysSidebarButtons = document.querySelectorAll('#keys-list-btn, #keys-generate-btn, #keys-add-btn, #keys-remotes-btn'); + keysSidebarButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.preventDefault(); + + // Update active sidebar button + keysSidebarButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Show appropriate key management view + const viewMap = { + 'keys-list-btn': 'keys-list-view', + 'keys-generate-btn': 'keys-generate-view', + 'keys-add-btn': 'keys-add-view', + 'keys-remotes-btn': 'keys-remotes-view' + }; + + const targetView = viewMap[button.id]; + if (targetView) { + showKeyManagementView(targetView); + + // Load content for specific views + if (targetView === 'keys-list-view') { + keyOperations.loadTrustedKeys(); + } else if (targetView === 'keys-remotes-view') { + keyOperations.loadRemoteSources(); + } + } + }); + }); + + // Generate key button + const generateKeyBtn = document.getElementById('generate-key-btn'); + if (generateKeyBtn) { + generateKeyBtn.addEventListener('click', keyOperations.handleGenerateKey); + } + + // Add remote button + const addRemoteBtn = document.getElementById('add-remote-btn'); + if (addRemoteBtn) { + addRemoteBtn.addEventListener('click', keyOperations.handleAddRemote); + } + + // Add key file button + const addKeyFileBtn = document.getElementById('add-key-file-btn'); + if (addKeyFileBtn) { + addKeyFileBtn.addEventListener('click', keyOperations.handleAddKey); + } + + // Dynamic buttons (will be added dynamically to keys list) + document.addEventListener('click', (e) => { + if (e.target.classList.contains('sync-remotes-btn')) { + keyOperations.handleSyncRemotes(); + } else if (e.target.classList.contains('remove-remote-btn')) { + const remoteName = e.target.dataset.remoteName; + keyOperations.handleRemoveRemote(remoteName); + } + }); +} + +// Show specific key management view +function showKeyManagementView(viewId) { + // Hide all key management views + const keyViews = document.querySelectorAll('.key-management-view'); + keyViews.forEach(view => view.classList.add('hidden')); + + // Show the target view + const targetView = document.getElementById(viewId); + if (targetView) { + targetView.classList.remove('hidden'); + } +} + // Initialize dependencies (called when module is loaded) function initializeDependencies(deps) { stateManager = deps.stateManager; domManager = deps.domManager; bundleOperations = deps.bundleOperations; + keyOperations = deps.keyOperations; fillWorkflow = deps.fillWorkflow; uiComponents = deps.uiComponents; opatHandler = deps.opatHandler; @@ -424,6 +520,8 @@ function initializeDependencies(deps) { module.exports = { initializeDependencies, setupEventListeners, + setupKeyManagementEventListeners, + showKeyManagementView, checkSignatureAndWarn, setupCategoryNavigation, updateWelcomeScreen, diff --git a/electron/renderer/key-operations.js b/electron/renderer/key-operations.js new file mode 100644 index 0000000..e282f5c --- /dev/null +++ b/electron/renderer/key-operations.js @@ -0,0 +1,509 @@ +// Key operations module for the 4DSTAR Bundle Manager +// Handles all key management operations (list, generate, add, remove, sync) + +const { ipcRenderer } = require('electron'); + +// Dependencies (injected by renderer-refactored.js) +let stateManager, domManager, uiComponents; + +// Initialize dependencies +function initializeDependencies(deps) { + stateManager = deps.stateManager; + domManager = deps.domManager; + uiComponents = deps.uiComponents; + console.log('[KEY_OPERATIONS] Dependencies initialized'); +} + +// === KEY LISTING === +async function loadKeys() { + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('list-keys'); + domManager.hideSpinner(); + + if (result.success) { + stateManager.setKeysState(result); + displayKeys(result); + return result; + } else { + domManager.showModal('Error', `Failed to load keys: ${result.error}`); + return null; + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to load keys: ${error.message}`); + return null; + } +} + +function displayKeys(keysData) { + const keysContainer = document.getElementById('keys-list-container'); + if (!keysContainer) return; + + if (keysData.total_count === 0) { + keysContainer.innerHTML = ` +
    +
    šŸ”‘
    +

    No Keys Found

    +

    No trusted keys are currently installed. Generate or add keys to get started.

    +
    + `; + return; + } + + // Update the main header with count and add action buttons + const mainHeader = document.querySelector('#keys-list-view .keys-header h3'); + if (mainHeader) { + mainHeader.textContent = `Trusted Keys (${keysData.total_count})`; + } + + // Add action buttons to the header if they don't exist + let keysHeader = document.querySelector('#keys-list-view .keys-header'); + if (keysHeader && !keysHeader.querySelector('.keys-actions')) { + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'keys-actions'; + actionsDiv.innerHTML = ` + + + `; + keysHeader.appendChild(actionsDiv); + } + + let html = ''; + + for (const [sourceName, keys] of Object.entries(keysData.keys)) { + html += ` +
    +
    +

    ${sourceName}

    + ${keys.length} keys +
    +
    + + + + + + + + + + + `; + + for (const key of keys) { + const shortFingerprint = key.fingerprint.substring(0, 16) + '...'; + html += ` + + + + + + + `; + } + + html += ` + +
    NameFingerprintSizeActions
    ${key.name}${shortFingerprint}${key.size_bytes} bytes + +
    +
    +
    + `; + } + + keysContainer.innerHTML = html; + + // Add event listeners for dynamically generated content + setupKeyListEventListeners(); +} + +function setupKeyListEventListeners() { + // Refresh keys button + const refreshBtn = document.getElementById('refresh-keys-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', loadKeys); + } + + // Sync remotes button + const syncBtn = document.getElementById('sync-remotes-btn'); + if (syncBtn) { + syncBtn.addEventListener('click', handleSyncRemotes); + } + + // Remove key buttons - now handled exclusively here (removed from event-handlers.js) + const removeButtons = document.querySelectorAll('.remove-key-btn'); + removeButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + const fingerprint = e.target.dataset.keyFingerprint; + const keyName = e.target.dataset.keyName; + handleRemoveKey(fingerprint, keyName); + }); + }); +} + +// === KEY GENERATION === +async function handleGenerateKey() { + const keyName = document.getElementById('generate-key-name')?.value || 'author_key'; + const keyType = document.getElementById('generate-key-type')?.value || 'ed25519'; + const outputDir = document.getElementById('generate-output-dir')?.value || '.'; + + if (!keyName.trim()) { + domManager.showModal('Error', 'Please enter a key name.'); + return; + } + + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('generate-key', { + keyName: keyName.trim(), + keyType: keyType, + outputDir: outputDir + }); + domManager.hideSpinner(); + + if (result.success) { + domManager.showModal('Success', ` +
    +

    Key Generated Successfully!

    +
    +

    Key Type: ${result.key_type.toUpperCase()}

    +

    Fingerprint: ${result.fingerprint}

    +

    Private Key: ${result.private_key_path}

    +

    Public Key: ${result.public_key_path}

    +
    +
    + āš ļø Important: Keep your private key secure and never share it! +
    +
    + `); + + // Clear form + document.getElementById('generate-key-name').value = ''; + document.getElementById('generate-output-dir').value = '.'; + } else { + domManager.showModal('Error', `Failed to generate key: ${result.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to generate key: ${error.message}`); + } +} + +// === KEY ADDITION === +async function handleAddKey() { + try { + // Use IPC-based file dialog instead of @electron/remote + const keyPath = await ipcRenderer.invoke('select-key-file'); + + if (!keyPath) { + return; // User canceled dialog + } + + domManager.showSpinner(); + const addResult = await ipcRenderer.invoke('add-key', keyPath); + domManager.hideSpinner(); + + if (addResult.success) { + if (addResult.already_existed) { + domManager.showModal('Info', `Key '${addResult.key_name}' already exists in trust store.`); + } else { + domManager.showModal('Success', ` +
    +
    +
    + + + + +
    +

    Key Added Successfully!

    +

    Your public key has been added to the trust store

    +
    + +
    +
    +
    + + + + Key Name +
    +
    ${addResult.key_name}
    +
    + +
    +
    + + + + + Fingerprint +
    +
    + ${addResult.fingerprint} + +
    +
    +
    + + +
    + `); + } + + // Refresh keys list + await loadKeys(); + } else { + domManager.showModal('Error', `Failed to add key: ${addResult.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to add key: ${error.message}`); + } +} + +// === KEY REMOVAL === +async function handleRemoveKey(fingerprint, keyName) { + const confirmed = await uiComponents.showConfirmDialog( + 'Remove Key', + `Are you sure you want to remove the key "${keyName}"?\n\nThis action cannot be undone.` + ); + + if (!confirmed) return; + + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('remove-key', fingerprint); + domManager.hideSpinner(); + + if (result.success) { + domManager.showModal('Success', `Removed ${result.removed_count} key(s) successfully.`); + + // Refresh keys list + await loadKeys(); + } else { + domManager.showModal('Error', `Failed to remove key: ${result.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to remove key: ${error.message}`); + } +} + +// === REMOTE SYNC === +async function handleSyncRemotes() { + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('sync-remotes'); + domManager.hideSpinner(); + + if (result.success) { + const successCount = result.synced_remotes.filter(r => r.status === 'success').length; + const failedCount = result.synced_remotes.filter(r => r.status === 'failed').length; + + let message = ` +
    +

    Remote Sync Completed

    +

    āœ… Successful: ${successCount}

    +

    āŒ Failed: ${failedCount}

    +

    šŸ“¦ Total keys synced: ${result.total_keys_synced}

    +
    + `; + + if (result.removed_remotes.length > 0) { + message += `

    Removed failing remotes: ${result.removed_remotes.join(', ')}

    `; + } + + domManager.showModal('Sync Results', message); + + // Refresh keys list + await loadKeys(); + } else { + domManager.showModal('Error', `Failed to sync remotes: ${result.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to sync remotes: ${error.message}`); + } +} + +// === REMOTE MANAGEMENT === +async function loadRemoteSources() { + try { + const result = await ipcRenderer.invoke('get-remote-sources'); + + if (result.success) { + displayRemoteSources(result.remotes); + return result.remotes; + } else { + domManager.showModal('Error', `Failed to load remote sources: ${result.error}`); + return []; + } + } catch (error) { + domManager.showModal('Error', `Failed to load remote sources: ${error.message}`); + return []; + } +} + +function displayRemoteSources(remotes) { + const remotesContainer = document.getElementById('remotes-list-container'); + if (!remotesContainer) return; + + if (remotes.length === 0) { + remotesContainer.innerHTML = ` +
    +
    🌐
    +

    No Remote Sources

    +

    No remote key sources are configured. Add remote repositories to sync keys automatically.

    +
    + `; + return; + } + + let html = ` +
    +

    Remote Key Sources (${remotes.length})

    +
    +
    + + + + + + + + + + + + `; + + for (const remote of remotes) { + const status = remote.exists ? 'āœ…' : 'āŒ'; + const statusText = remote.exists ? 'Synced' : 'Not synced'; + + html += ` + + + + + + + + `; + } + + html += ` + +
    StatusNameURLKeysActions
    ${status}${remote.name}${remote.url}${remote.keys_count} + +
    +
    + `; + + remotesContainer.innerHTML = html; + + // Add event listeners for remove buttons + const removeButtons = document.querySelectorAll('.remove-remote-btn'); + removeButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + const remoteName = e.target.dataset.remoteName; + handleRemoveRemoteSource(remoteName); + }); + }); +} + +async function handleAddRemoteSource() { + const name = document.getElementById('remote-name')?.value; + const url = document.getElementById('remote-url')?.value; + + if (!name || !url) { + domManager.showModal('Error', 'Please enter both name and URL for the remote source.'); + return; + } + + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('add-remote-source', { name: name.trim(), url: url.trim() }); + domManager.hideSpinner(); + + if (result.success) { + domManager.showModal('Success', `Remote source '${result.name}' added successfully.`); + + // Clear form + document.getElementById('remote-name').value = ''; + document.getElementById('remote-url').value = ''; + + // Refresh remotes list + await loadRemoteSources(); + } else { + domManager.showModal('Error', `Failed to add remote source: ${result.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to add remote source: ${error.message}`); + } +} + +async function handleRemoveRemoteSource(remoteName) { + const confirmed = await uiComponents.showConfirmDialog( + 'Remove Remote Source', + `Are you sure you want to remove the remote source "${remoteName}"?\n\nThis will also remove all keys synced from this source.` + ); + + if (!confirmed) return; + + try { + domManager.showSpinner(); + const result = await ipcRenderer.invoke('remove-remote-source', remoteName); + domManager.hideSpinner(); + + if (result.success) { + domManager.showModal('Success', `Remote source '${result.name}' removed successfully.`); + + // Refresh remotes list and keys list + await loadRemoteSources(); + await loadKeys(); + } else { + domManager.showModal('Error', `Failed to remove remote source: ${result.error}`); + } + } catch (error) { + domManager.hideSpinner(); + domManager.showModal('Error', `Failed to remove remote source: ${error.message}`); + } +} + +// Export functions +module.exports = { + initializeDependencies, + loadKeys, + loadTrustedKeys: loadKeys, // Alias for compatibility + handleGenerateKey, + handleAddKey, + handleRemoveKey, + handleSyncRemotes, + loadRemoteSources, + handleAddRemoteSource, + handleRemoveRemoteSource +}; diff --git a/electron/renderer/state-manager.js b/electron/renderer/state-manager.js index b181659..233790a 100644 --- a/electron/renderer/state-manager.js +++ b/electron/renderer/state-manager.js @@ -11,6 +11,10 @@ let pendingOperation = null; // Store the operation to execute after warning con // Current OPAT file state let currentOPATFile = null; +// Current key management state +let currentKeys = null; +let keyOperationInProgress = false; + // Bundle state management const getBundleState = () => ({ currentBundle, @@ -67,6 +71,27 @@ const clearOPATFile = () => { currentOPATFile = null; }; +// Key management state functions +const setKeysState = (keysData) => { + currentKeys = keysData; +}; + +const getKeysState = () => { + return currentKeys; +}; + +const clearKeysState = () => { + currentKeys = null; +}; + +const setKeyOperationInProgress = (inProgress) => { + keyOperationInProgress = inProgress; +}; + +const getKeyOperationInProgress = () => { + return keyOperationInProgress; +}; + // Export state management functions module.exports = { // Bundle state @@ -86,6 +111,13 @@ module.exports = { getOPATFile, clearOPATFile, + // Key management state + setKeysState, + getKeysState, + clearKeysState, + setKeyOperationInProgress, + getKeyOperationInProgress, + // Direct state access (for compatibility) getCurrentBundle: () => currentBundle, getCurrentBundlePath: () => currentBundlePath, diff --git a/electron/renderer/ui-components.js b/electron/renderer/ui-components.js index 970cda9..26e96e3 100644 --- a/electron/renderer/ui-components.js +++ b/electron/renderer/ui-components.js @@ -163,6 +163,14 @@ function hideSaveOptionsModal() { elements.saveOptionsModal.classList.add('hidden'); } +// Show confirmation dialog +function showConfirmDialog(title, message) { + return new Promise((resolve) => { + const confirmed = confirm(`${title}\n\n${message}`); + resolve(confirmed); + }); +} + // Initialize dependencies (called when module is loaded) function initializeDependencies(deps) { stateManager = deps.stateManager; @@ -179,5 +187,6 @@ module.exports = { checkForChanges, updateSaveButtonVisibility, showSaveOptionsModal, - hideSaveOptionsModal + hideSaveOptionsModal, + showConfirmDialog }; diff --git a/electron/styles.css b/electron/styles.css index 6f7e17a..0faa407 100644 --- a/electron/styles.css +++ b/electron/styles.css @@ -903,6 +903,711 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover { background: #475569; } +/* ===== KEY MANAGEMENT STYLING ===== */ + +/* Key Management Views Container */ +#keys-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--background-color); +} + +/* Individual Key Management Views */ +.key-management-view { + flex: 1; + overflow-y: auto; + padding: 2rem; + background: var(--background-color); +} + +/* Key Management Headers */ +.keys-header, +.generate-key-header, +.add-key-header, +.remotes-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: flex-end; + flex-wrap: wrap; + gap: 1rem; +} + +.keys-header .keys-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.keys-header h3, +.generate-key-header h3, +.add-key-header h3, +.remotes-header h3 { + color: var(--text-color); + font-size: 1.8rem; + font-weight: 600; + margin: 0 0 0.5rem 0; +} + +.keys-header p, +.generate-key-header p, +.add-key-header p, +.remotes-header p { + color: var(--text-light); + font-size: 1rem; + margin: 0; + line-height: 1.5; +} + +/* Form Styling */ +.generate-key-form, +.add-key-form { + background: white; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 600; + color: var(--text-color); + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 1rem; + background: white; + color: var(--text-color); + transition: all 0.2s ease; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-group small { + display: block; + color: var(--text-light); + font-size: 0.85rem; + margin-top: 0.25rem; +} + +.form-actions { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 2fr auto; + gap: 1rem; + align-items: end; + margin-bottom: 1.5rem; +} + +/* Action Buttons */ +.action-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.action-button.primary { + background: var(--primary-color); + color: white; +} + +.action-button.primary:hover { + background: var(--secondary-color); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.action-button.secondary { + background: white; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.action-button.secondary:hover { + background: var(--primary-color); + color: white; +} + +.action-button.danger { + background: #ef4444; + color: white; +} + +.action-button.danger:hover { + background: #dc2626; +} + +/* Notice Banners */ +.security-notice, +.info-notice { + margin-top: 2rem; +} + +.warning-banner, +.info-banner { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: 8px; + border-left: 4px solid; +} + +.warning-banner { + background: #fef3c7; + border-left-color: #f59e0b; +} + +.info-banner { + background: #dbeafe; + border-left-color: var(--primary-color); +} + +.warning-icon, +.info-icon { + font-size: 1.2rem; + flex-shrink: 0; + margin-top: 0.1rem; +} + +.warning-text, +.info-text { + flex: 1; +} + +.warning-text strong, +.info-text strong { + color: #92400e; + font-weight: 600; +} + +.info-text strong { + color: #1e40af; +} + +/* Keys List Container */ +#keys-list-container { + background: white; + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + overflow: hidden; +} + +/* Key Source Sections */ +.key-source-section { + border-bottom: 1px solid var(--border-color); +} + +.key-source-section:last-child { + border-bottom: none; +} + +.source-header { + background: #f8fafc; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.source-header h4 { + margin: 0; + color: var(--text-color); + font-size: 1.1rem; + font-weight: 600; +} + +.key-count { + background: var(--primary-color); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; +} + +.keys-table-wrapper { + overflow-x: auto; +} + +.keys-table { + width: 100%; +} + +.keys-table table { + width: 100%; + border-collapse: collapse; +} + +.keys-table th, +.keys-table td { + padding: 1rem 1.5rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.keys-table th { + background: #f8fafc; + font-weight: 600; + color: var(--text-color); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.keys-table td { + color: var(--text-color); + font-size: 0.95rem; +} + +.keys-table tr:last-child td { + border-bottom: none; +} + +.keys-table tr:hover { + background: #f8fafc; +} + +.key-fingerprint { + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 0.85rem; + color: var(--text-light); + background: #f1f5f9; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.key-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-small { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: var(--secondary-color); + transform: translateY(-1px); +} + +/* Remote Sources Styling */ +.add-remote-form { + background: white; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.add-remote-form h4 { + color: var(--text-color); + font-size: 1.2rem; + font-weight: 600; + margin: 0 0 1.5rem 0; +} + +#remotes-list-container { + background: white; + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + overflow: hidden; +} + +.remotes-table { + width: 100%; + border-collapse: collapse; +} + +.remotes-table th, +.remotes-table td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.remotes-table th { + background: #f8fafc; + font-weight: 600; + color: var(--text-color); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.remotes-table tr:hover { + background: #f8fafc; +} + +/* Empty States */ +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: var(--text-light); +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h3 { + color: var(--text-color); + font-size: 1.3rem; + font-weight: 600; + margin: 0 0 0.5rem 0; +} + +.empty-state p { + color: var(--text-light); + font-size: 1rem; + margin: 0; + line-height: 1.5; +} + +/* Dark Mode Support */ +body.dark-mode .key-management-view { + background: var(--bg-color); +} + +body.dark-mode .generate-key-form, +body.dark-mode .add-key-form, +body.dark-mode .add-remote-form, +body.dark-mode #keys-list-container, +body.dark-mode #remotes-list-container { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .source-header { + background: #0f172a; +} + +body.dark-mode .keys-table th, +body.dark-mode .remotes-table th { + background: #0f172a; + color: #f1f5f9; +} + +body.dark-mode .keys-table tr:hover, +body.dark-mode .remotes-table tr:hover { + background: #0f172a; +} + +body.dark-mode .form-group input, +body.dark-mode .form-group select { + background: #0f172a; + border-color: #334155; + color: #f1f5f9; +} + +body.dark-mode .form-group input:focus, +body.dark-mode .form-group select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +body.dark-mode .key-fingerprint { + background: #0f172a; + color: #94a3b8; +} + +body.dark-mode .warning-banner { + background: rgba(245, 158, 11, 0.1); + border-left-color: #f59e0b; +} + +body.dark-mode .info-banner { + background: rgba(59, 130, 246, 0.1); + border-left-color: var(--primary-color); +} + +body.dark-mode .warning-text strong { + color: #fbbf24; +} + +body.dark-mode .info-text strong { + color: #60a5fa; +} + +/* Modern Key Add Success Modal */ +.key-add-success-modern { + max-width: 500px; + margin: 0 auto; + text-align: center; +} + +.success-header { + margin-bottom: 2rem; +} + +.success-icon { + margin: 0 auto 1rem auto; + width: 48px; + height: 48px; +} + +.success-header h3 { + color: var(--text-color); + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.5rem 0; +} + +.success-subtitle { + color: var(--text-light); + font-size: 1rem; + margin: 0; + line-height: 1.5; +} + +.key-details-card { + background: #f8fafc; + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + margin: 1.5rem 0; + text-align: left; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.detail-row:last-child { + margin-bottom: 0; +} + +.detail-label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-light); + font-size: 0.9rem; + font-weight: 500; + min-width: 100px; + flex-shrink: 0; +} + +.detail-label svg { + color: var(--text-light); +} + +.detail-value { + flex: 1; + color: var(--text-color); + font-size: 0.95rem; + word-break: break-all; +} + +.fingerprint-value { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.fingerprint-value code { + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 0.8rem; + background: #e2e8f0; + color: #475569; + padding: 0.25rem 0.5rem; + border-radius: 4px; + flex: 1; + min-width: 0; +} + +.copy-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 0.25rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.copy-btn:hover { + background: var(--secondary-color); + transform: translateY(-1px); +} + +.success-footer { + margin-top: 1.5rem; +} + +.info-banner { + display: flex; + align-items: center; + gap: 0.75rem; + background: #dbeafe; + border: 1px solid #93c5fd; + border-radius: 8px; + padding: 0.75rem 1rem; + color: #1e40af; + font-size: 0.9rem; + text-align: left; +} + +.info-banner svg { + color: #3b82f6; + flex-shrink: 0; +} + +/* Dark Mode Support for Modern Modal */ +body.dark-mode .key-details-card { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .fingerprint-value code { + background: #0f172a; + color: #94a3b8; +} + +body.dark-mode .info-banner { + background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.3); + color: #60a5fa; +} + +body.dark-mode .info-banner svg { + color: #60a5fa; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .key-management-view { + padding: 1.5rem; + } + + .form-row { + grid-template-columns: 1fr; + gap: 1rem; + } +} + +@media (max-width: 768px) { + .key-management-view { + padding: 1rem; + } + + .generate-key-form, + .add-key-form, + .add-remote-form { + padding: 1.5rem; + } + + .keys-table, + .remotes-table { + font-size: 0.9rem; + } + + .keys-table th, + .keys-table td, + .remotes-table th, + .remotes-table td { + padding: 0.75rem; + } + + .key-actions { + flex-direction: column; + gap: 0.25rem; + } + + .key-add-success-modern { + max-width: 100%; + } + + .fingerprint-value { + flex-direction: column; + align-items: stretch; + } + + .detail-row { + flex-direction: column; + gap: 0.5rem; + } + + .detail-label { + min-width: auto; + } +} + #welcome-screen h1 { font-size: 2rem; margin-bottom: 0.5rem; diff --git a/electron/~/Desktop/test.pem b/electron/~/Desktop/test.pem new file mode 100644 index 0000000..9875347 --- /dev/null +++ b/electron/~/Desktop/test.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEINS0UV/JuxLOW7PyegagGJfCP13oVrBXXBhMBRV0YK5+ +-----END PRIVATE KEY----- diff --git a/electron/~/Desktop/test.pub b/electron/~/Desktop/test.pub new file mode 100644 index 0000000..e23eaba --- /dev/null +++ b/electron/~/Desktop/test.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILXntMn/iLZI5hHaU+b9WYzE8E4D04gseoRbexZeIbrU \ No newline at end of file diff --git a/electron/~/Desktop/test.pub.pem b/electron/~/Desktop/test.pub.pem new file mode 100644 index 0000000..23e21ac --- /dev/null +++ b/electron/~/Desktop/test.pub.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAtee0yf+ItkjmEdpT5v1ZjMTwTgPTiCx6hFt7Fl4hutQ= +-----END PUBLIC KEY----- diff --git a/fourdst/cli/keys/add.py b/fourdst/cli/keys/add.py index c70c395..213bf6d 100644 --- a/fourdst/cli/keys/add.py +++ b/fourdst/cli/keys/add.py @@ -1,23 +1,20 @@ # fourdst/cli/keys/add.py import typer -import shutil from pathlib import Path -from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH - -MANUAL_KEYS_DIR = LOCAL_TRUST_STORE_PATH / "manual" +from fourdst.core.keys import add_key def keys_add( key_path: Path = typer.Argument(..., help="Path to the public key file to add.", exists=True, readable=True) ): """Adds a single public key to the local trust store.""" - MANUAL_KEYS_DIR.mkdir(parents=True, exist_ok=True) + result = add_key(key_path) - destination = MANUAL_KEYS_DIR / key_path.name - if destination.exists(): - # check content - if destination.read_bytes() == key_path.read_bytes(): - typer.secho(f"Key '{key_path.name}' with same content already exists.", fg=typer.colors.YELLOW) - return - - shutil.copy(key_path, destination) - typer.secho(f"āœ… Key '{key_path.name}' added to manual trust store.", fg=typer.colors.GREEN) + if result["success"]: + if result["already_existed"]: + typer.secho(f"Key '{result['key_name']}' with same content already exists.", fg=typer.colors.YELLOW) + else: + typer.secho(f"āœ… Key '{result['key_name']}' added to manual trust store.", fg=typer.colors.GREEN) + typer.echo(f"Fingerprint: {result['fingerprint']}") + else: + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) + raise typer.Exit(code=1) diff --git a/fourdst/cli/keys/generate.py b/fourdst/cli/keys/generate.py index e2d454c..e974c72 100644 --- a/fourdst/cli/keys/generate.py +++ b/fourdst/cli/keys/generate.py @@ -1,60 +1,38 @@ # fourdst/cli/keys/generate.py import typer -import sys from pathlib import Path -from cryptography.hazmat.primitives.asymmetric import ed25519, rsa -from cryptography.hazmat.primitives import serialization +from fourdst.core.keys import generate_key keys_app = typer.Typer() @keys_app.command("generate") def keys_generate( key_name: str = typer.Option("author_key", "--name", "-n", help="The base name for the generated key files."), - key_type: str = typer.Option("ed25519", "--type", "-t", help="Type of key to generate (ed25519|rsa).", case_sensitive=False) + key_type: str = typer.Option("ed25519", "--type", "-t", help="Type of key to generate (ed25519|rsa).", case_sensitive=False), + output_dir: str = typer.Option(".", "--output", "-o", help="Directory to save the generated keys.") ): """ Generates a new Ed25519 or RSA key pair for signing bundles. """ - # Define PEM-formatted key file paths - private_key_path = Path(f"{key_name}.pem") - public_key_path = Path(f"{key_name}.pub.pem") - - if private_key_path.exists() or public_key_path.exists(): - print(f"Error: Key files '{private_key_path}' or '{public_key_path}' already exist.", file=sys.stderr) - raise typer.Exit(code=1) - - # Generate key based on requested type - if key_type.lower() == "ed25519": - typer.echo("Generating Ed25519 key pair in PEM format via cryptography...") - private_key_obj = ed25519.Ed25519PrivateKey.generate() - elif key_type.lower() == "rsa": - typer.echo("Generating RSA-2048 key pair in PEM format via cryptography...") - private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=2048) + def progress_callback(message): + typer.echo(message) + + result = generate_key( + key_name=key_name, + key_type=key_type, + output_dir=Path(output_dir), + progress_callback=progress_callback + ) + + if result["success"]: + typer.echo("\nāœ… PEM and OpenSSH-compatible keys generated successfully!") + typer.echo(f" -> Private Key (KEEP SECRET): {result['private_key_path']}") + typer.echo(f" -> Public Key (SHARE): {result['public_key_path']}") + typer.echo(f" -> OpenSSH Public Key: {result['openssh_public_key_path']}") + typer.echo(f" -> Key Type: {result['key_type'].upper()}") + typer.echo(f" -> Fingerprint: {result['fingerprint']}") + typer.echo("\nShare the public key with users who need to trust your bundles.") else: - typer.secho(f"Unsupported key type: {key_type}", fg=typer.colors.RED) + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) raise typer.Exit(code=1) - # Serialize private key to PEM - priv_pem = private_key_obj.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - private_key_path.write_bytes(priv_pem) - # Derive and serialize public key to PEM - public_key_obj = private_key_obj.public_key() - pub_pem = public_key_obj.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo - ) - public_key_path.write_bytes(pub_pem) - # Also write OpenSSH-compatible public key - openssh_pub = public_key_obj.public_bytes( - encoding=serialization.Encoding.OpenSSH, - format=serialization.PublicFormat.OpenSSH - ) - Path(f"{key_name}.pub").write_bytes(openssh_pub) - print("\nāœ… PEM and OpenSSH-compatible keys generated successfully!") - print(f" -> Private Key (KEEP SECRET): {private_key_path.resolve()}") - print(f" -> Public Key (SHARE): {public_key_path.resolve()}") - print("\nShare the public key with users who need to trust your bundles.") diff --git a/fourdst/cli/keys/list.py b/fourdst/cli/keys/list.py index 37c58c9..38493cc 100644 --- a/fourdst/cli/keys/list.py +++ b/fourdst/cli/keys/list.py @@ -1,23 +1,25 @@ # fourdst/cli/keys/list.py import typer -from pathlib import Path -from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH +from fourdst.core.keys import list_keys def keys_list(): """Lists all trusted public keys.""" - if not LOCAL_TRUST_STORE_PATH.exists(): - typer.echo("Trust store not found.") - return - - keys_found = False - for source_dir in LOCAL_TRUST_STORE_PATH.iterdir(): - if source_dir.is_dir(): - keys = list(source_dir.glob("*.pub")) - if keys: - keys_found = True - typer.secho(f"\n--- Source: {source_dir.name} ---", bold=True) - for key_file in keys: - typer.echo(f" - {key_file.name}") + result = list_keys() - if not keys_found: + if not result["success"]: + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if result["total_count"] == 0: typer.echo("No trusted keys found.") + return + + typer.echo(f"Found {result['total_count']} trusted keys:\n") + + for source_name, keys in result["keys"].items(): + typer.secho(f"--- Source: {source_name} ---", bold=True) + for key_info in keys: + typer.echo(f" - {key_info['name']}") + typer.echo(f" Fingerprint: {key_info['fingerprint']}") + typer.echo(f" Size: {key_info['size_bytes']} bytes") + typer.echo() # Empty line between sources diff --git a/fourdst/cli/keys/remote/add.py b/fourdst/cli/keys/remote/add.py index cd7907e..452f2e0 100644 --- a/fourdst/cli/keys/remote/add.py +++ b/fourdst/cli/keys/remote/add.py @@ -1,31 +1,16 @@ # fourdst/cli/keys/remote/add.py import typer -import json -from pathlib import Path -from fourdst.cli.common.config import FOURDST_CONFIG_DIR - -KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json" +from fourdst.core.keys import add_remote_source def remote_add( url: str = typer.Argument(..., help="The URL of the Git repository."), name: str = typer.Argument(..., help="A local name for the remote.") ): """Adds a new remote key source.""" - FOURDST_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + result = add_remote_source(name, url) - if KEY_REMOTES_CONFIG.exists(): - with open(KEY_REMOTES_CONFIG, 'r') as f: - config = json.load(f) + if result["success"]: + typer.secho(f"āœ… Remote '{result['name']}' added.", fg=typer.colors.GREEN) else: - config = {"remotes": []} - - if any(r['name'] == name for r in config['remotes']): - typer.secho(f"Error: Remote with name '{name}' already exists.", fg=typer.colors.RED) + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) raise typer.Exit(code=1) - - config['remotes'].append({"name": name, "url": url}) - - with open(KEY_REMOTES_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - - typer.secho(f"āœ… Remote '{name}' added.", fg=typer.colors.GREEN) diff --git a/fourdst/cli/keys/remote/list.py b/fourdst/cli/keys/remote/list.py index 54c4dd2..ee6e70a 100644 --- a/fourdst/cli/keys/remote/list.py +++ b/fourdst/cli/keys/remote/list.py @@ -1,24 +1,24 @@ # fourdst/cli/keys/remote/list.py import typer -import json -from pathlib import Path -from fourdst.cli.common.config import FOURDST_CONFIG_DIR - -KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json" +from fourdst.core.keys import get_remote_sources def remote_list(): """Lists all configured remote key sources.""" - if not KEY_REMOTES_CONFIG.exists(): - typer.echo("No remotes configured.") - return - - with open(KEY_REMOTES_CONFIG, 'r') as f: - config = json.load(f) - - if not config.get("remotes"): + result = get_remote_sources() + + if not result["success"]: + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if not result["remotes"]: typer.echo("No remotes configured.") return typer.secho("Configured Key Remotes:", bold=True) - for remote in config['remotes']: - typer.echo(f" - {remote['name']}: {remote['url']}") + for remote in result["remotes"]: + status = "āœ…" if remote["exists"] else "āŒ" + typer.echo(f" {status} {remote['name']}: {remote['url']}") + if remote["exists"]: + typer.echo(f" Keys: {remote['keys_count']}") + else: + typer.echo(f" Status: Not synced yet") diff --git a/fourdst/cli/keys/remote/remove.py b/fourdst/cli/keys/remote/remove.py index db002c1..67ac562 100644 --- a/fourdst/cli/keys/remote/remove.py +++ b/fourdst/cli/keys/remote/remove.py @@ -1,30 +1,15 @@ # fourdst/cli/keys/remote/remove.py import typer -import json -from pathlib import Path -from fourdst.cli.common.config import FOURDST_CONFIG_DIR - -KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json" +from fourdst.core.keys import remove_remote_source def remote_remove( name: str = typer.Argument(..., help="The name of the remote to remove.") ): """Removes a remote key source.""" - if not KEY_REMOTES_CONFIG.exists(): - typer.secho("Error: No remotes configured.", fg=typer.colors.RED) + result = remove_remote_source(name) + + if result["success"]: + typer.secho(f"āœ… Remote '{result['name']}' removed.", fg=typer.colors.GREEN) + else: + typer.secho(f"Error: {result['error']}", fg=typer.colors.RED) raise typer.Exit(code=1) - - with open(KEY_REMOTES_CONFIG, 'r') as f: - config = json.load(f) - - original_len = len(config['remotes']) - config['remotes'] = [r for r in config['remotes'] if r['name'] != name] - - if len(config['remotes']) == original_len: - typer.secho(f"Error: Remote '{name}' not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - with open(KEY_REMOTES_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - - typer.secho(f"āœ… Remote '{name}' removed.", fg=typer.colors.GREEN) diff --git a/fourdst/cli/keys/remove.py b/fourdst/cli/keys/remove.py index 8a22530..de9b7d5 100644 --- a/fourdst/cli/keys/remove.py +++ b/fourdst/cli/keys/remove.py @@ -2,60 +2,52 @@ import typer import questionary from pathlib import Path -import hashlib -from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH - -def get_key_fingerprint(key_path: Path) -> str: - """Generates a SHA256 fingerprint for a public key.""" - pub_key_bytes = key_path.read_bytes() - # Assuming OpenSSH format, the fingerprint is based on the raw public key bytes - # For simplicity, we'll hash the whole file content. - return "sha256:" + hashlib.sha256(pub_key_bytes).hexdigest() +from fourdst.core.keys import remove_key, list_keys def keys_remove( key_path: Path = typer.Argument(None, help="Path to the public key file to remove.", exists=True, readable=True) ): """Removes a single public key from the local trust store.""" - if not LOCAL_TRUST_STORE_PATH.exists(): - typer.secho("Trust store not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) - if key_path: - # Remove by content matching - target_content = key_path.read_bytes() - key_removed = False - for source_dir in LOCAL_TRUST_STORE_PATH.iterdir(): - if source_dir.is_dir(): - for pub_key in source_dir.glob("*.pub"): - if pub_key.read_bytes() == target_content: - pub_key.unlink() - typer.secho(f"āœ… Removed key '{pub_key.name}' from source '{source_dir.name}'.", fg=typer.colors.GREEN) - key_removed = True - if not key_removed: - typer.secho("No matching key found to remove.", fg=typer.colors.YELLOW) + # Remove by path + result = remove_key(str(key_path)) + + if result["success"]: + for removed_key in result["removed_keys"]: + typer.secho(f"āœ… Removed key '{removed_key['name']}' from source '{removed_key['source']}'.", fg=typer.colors.GREEN) + else: + typer.secho(f"Error: {result['error']}", fg=typer.colors.YELLOW) else: # Interactive removal - all_keys = [] - for source_dir in LOCAL_TRUST_STORE_PATH.iterdir(): - if source_dir.is_dir(): - for pub_key in source_dir.glob("*.pub"): - all_keys.append(pub_key) + keys_result = list_keys() - if not all_keys: + if not keys_result["success"]: + typer.secho(f"Error: {keys_result['error']}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if keys_result["total_count"] == 0: typer.echo("No keys to remove.") raise typer.Exit() - choices = [ - { - "name": f"{key.relative_to(LOCAL_TRUST_STORE_PATH)} ({get_key_fingerprint(key)})", - "value": key - } for key in all_keys - ] + # Build choices for interactive selection + choices = [] + for source_name, keys in keys_result["keys"].items(): + for key_info in keys: + relative_path = f"{source_name}/{key_info['name']}" + choice_name = f"{relative_path} ({key_info['fingerprint']})" + choices.append({ + "name": choice_name, + "value": key_info['fingerprint'] # Use fingerprint as identifier + }) - selected_to_remove = questionary.checkbox("Select keys to remove:", choices=choices).ask() + selected_fingerprints = questionary.checkbox("Select keys to remove:", choices=choices).ask() - if selected_to_remove: - for key_to_remove in selected_to_remove: - key_to_remove.unlink() - typer.secho(f"āœ… Removed key '{key_to_remove.name}'.", fg=typer.colors.GREEN) + if selected_fingerprints: + for fingerprint in selected_fingerprints: + result = remove_key(fingerprint) + if result["success"]: + for removed_key in result["removed_keys"]: + typer.secho(f"āœ… Removed key '{removed_key['name']}'.", fg=typer.colors.GREEN) + else: + typer.secho(f"Error removing key: {result['error']}", fg=typer.colors.RED) diff --git a/fourdst/cli/keys/sync.py b/fourdst/cli/keys/sync.py index dad2054..361b317 100644 --- a/fourdst/cli/keys/sync.py +++ b/fourdst/cli/keys/sync.py @@ -1,14 +1,8 @@ # fourdst/cli/keys/sync.py import typer -import shutil -import json -from pathlib import Path import questionary -from fourdst.cli.common.config import FOURDST_CONFIG_DIR, LOCAL_TRUST_STORE_PATH - -KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json" -REMOTES_DIR = LOCAL_TRUST_STORE_PATH / "remotes" +from fourdst.core.keys import sync_remotes, remove_remote_source keys_app = typer.Typer() @@ -17,50 +11,42 @@ def keys_sync(): """ Syncs the local trust store with all configured remote Git repositories. """ - if not KEY_REMOTES_CONFIG.exists(): - typer.secho("No remotes configured. Use 'fourdst-cli keys remote add' to add one.", fg=typer.colors.YELLOW) - raise typer.Exit() - - with open(KEY_REMOTES_CONFIG, 'r') as f: - config = json.load(f) + def progress_callback(message): + typer.echo(message) - remotes = config.get("remotes", []) - if not remotes: - typer.secho("No remotes configured.", fg=typer.colors.YELLOW) - raise typer.Exit() - - REMOTES_DIR.mkdir(parents=True, exist_ok=True) + result = sync_remotes(progress_callback=progress_callback) - remotes_to_remove = [] - - for remote in remotes: - name = remote['name'] - url = remote['url'] - remote_path = REMOTES_DIR / name - - typer.secho(f"--- Syncing remote '{name}' from {url} ---", bold=True) - - try: - if remote_path.exists(): - run_command(["git", "pull"], cwd=remote_path) + if not result["success"]: + typer.secho(f"Error: {result['error']}", fg=typer.colors.YELLOW) + raise typer.Exit() + + # Display results + success_count = len([r for r in result["synced_remotes"] if r["status"] == "success"]) + failed_count = len([r for r in result["synced_remotes"] if r["status"] == "failed"]) + + typer.echo(f"\nSync completed:") + typer.echo(f" āœ… Successful: {success_count}") + typer.echo(f" āŒ Failed: {failed_count}") + typer.echo(f" šŸ“¦ Total keys synced: {result['total_keys_synced']}") + + # Show details for each remote + for remote_info in result["synced_remotes"]: + if remote_info["status"] == "success": + typer.secho(f" āœ… {remote_info['name']}: {remote_info.get('keys_count', 0)} keys", fg=typer.colors.GREEN) + else: + typer.secho(f" āŒ {remote_info['name']}: {remote_info['error']}", fg=typer.colors.RED) + + # Handle removed remotes + if result["removed_remotes"]: + typer.secho(f"\nRemoved failing remotes: {', '.join(result['removed_remotes'])}", fg=typer.colors.YELLOW) + + # Ask about failed remotes that weren't automatically removed + failed_remotes = [r for r in result["synced_remotes"] if r["status"] == "failed" and r["name"] not in result["removed_remotes"]] + for remote_info in failed_remotes: + if questionary.confirm(f"Do you want to remove the failing remote '{remote_info['name']}'?").ask(): + remove_result = remove_remote_source(remote_info['name']) + if remove_result["success"]: + typer.secho(f"āœ… Removed remote '{remote_info['name']}'", fg=typer.colors.GREEN) else: - run_command(["git", "clone", "--depth", "1", url, str(remote_path)]) - - # Clean up non-public key files - for item in remote_path.iterdir(): - if item.is_file() and item.suffix != '.pub': - item.unlink() - - typer.secho(f"āœ… Sync successful for '{name}'.", fg=typer.colors.GREEN) - - except Exception as e: - typer.secho(f"āš ļø Failed to sync remote '{name}': {e}", fg=typer.colors.YELLOW) - if questionary.confirm(f"Do you want to remove the remote '{name}'?").ask(): - remotes_to_remove.append(name) - - if remotes_to_remove: - config['remotes'] = [r for r in config['remotes'] if r['name'] not in remotes_to_remove] - with open(KEY_REMOTES_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - typer.secho(f"Removed failing remotes: {', '.join(remotes_to_remove)}", fg=typer.colors.YELLOW) + typer.secho(f"āŒ Failed to remove remote '{remote_info['name']}': {remove_result['error']}", fg=typer.colors.RED) diff --git a/fourdst/core/build.py b/fourdst/core/build.py index 82d5368..06a3f46 100644 --- a/fourdst/core/build.py +++ b/fourdst/core/build.py @@ -3,11 +3,9 @@ import os import subprocess import zipfile -import docker import io import tarfile from pathlib import Path -import zipfile try: import docker @@ -170,8 +168,11 @@ def build_plugin_in_docker(sdist_path: Path, build_dir: Path, target: dict, plug # Use the tarfile module for robust extraction bits, _ = container.get_archive(str(container_build_dir / "abi_details.txt")) with tarfile.open(fileobj=io.BytesIO(b''.join(bits))) as tar: - member = tar.getmembers()[0] - extracted_file = tar.extractfile(member) + extracted_file = None + for member in tar.getmembers(): + if member.isfile(): + extracted_file = tar.extractfile(member) + break if not extracted_file: raise FileNotFoundError("Could not extract abi_details.txt from container archive.") abi_details_content = extracted_file.read() diff --git a/fourdst/core/keys.py b/fourdst/core/keys.py new file mode 100644 index 0000000..6c76702 --- /dev/null +++ b/fourdst/core/keys.py @@ -0,0 +1,746 @@ +# fourdst/core/keys.py +""" +Core key management functions for 4DSTAR. + +This module provides the core functionality for managing cryptographic keys +used for bundle signing and verification. All key operations should go through +these functions to maintain consistency between CLI and Electron interfaces. + +ARCHITECTURE: +============= +- All functions return JSON-serializable dictionaries +- Progress callbacks are separate from return values +- Consistent error format: {"success": false, "error": "message"} +- Functions handle both interactive and programmatic usage +""" + +import os +import sys +import json +import shutil +import hashlib +import logging +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional, Callable, List + +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from cryptography.hazmat.primitives import serialization + +from fourdst.core.config import FOURDST_CONFIG_DIR, LOCAL_TRUST_STORE_PATH +from fourdst.core.utils import run_command + +# Configure logging to go to stderr only, never stdout +logging.basicConfig(stream=sys.stderr, level=logging.INFO) + +# Key management paths +MANUAL_KEYS_DIR = LOCAL_TRUST_STORE_PATH / "manual" +REMOTES_DIR = LOCAL_TRUST_STORE_PATH / "remotes" +KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json" + + +def list_keys(progress_callback: Optional[Callable] = None) -> Dict[str, Any]: + """ + Lists all trusted public keys organized by source. + + Returns: + Dict with structure: + { + "success": bool, + "keys": { + "source_name": [ + { + "name": str, + "path": str, + "fingerprint": str, + "size_bytes": int + } + ] + }, + "total_count": int + } + + On error: + { + "success": false, + "error": "error message" + } + """ + def report_progress(message): + if progress_callback: + progress_callback(message) + else: + logging.info(message) + + try: + report_progress("Scanning trust store for keys...") + + if not LOCAL_TRUST_STORE_PATH.exists(): + return { + "success": True, + "keys": {}, + "total_count": 0, + "message": "Trust store not found - no keys available" + } + + keys_by_source = {} + total_count = 0 + + for source_dir in LOCAL_TRUST_STORE_PATH.iterdir(): + if source_dir.is_dir(): + source_keys = [] + # Look for both .pub and .pub.pem files + key_patterns = ["*.pub", "*.pub.pem"] + for pattern in key_patterns: + for key_file in source_dir.glob(pattern): + try: + fingerprint = _get_key_fingerprint(key_file) + key_info = { + "name": key_file.name, + "path": str(key_file), + "fingerprint": fingerprint, + "size_bytes": key_file.stat().st_size + } + source_keys.append(key_info) + total_count += 1 + except Exception as e: + report_progress(f"Warning: Could not process key {key_file}: {e}") + + if source_keys: + keys_by_source[source_dir.name] = source_keys + + report_progress(f"Found {total_count} keys across {len(keys_by_source)} sources") + + return { + "success": True, + "keys": keys_by_source, + "total_count": total_count + } + + except Exception as e: + logging.exception(f"Unexpected error listing keys") + return { + "success": False, + "error": f"Failed to list keys: {str(e)}" + } + + +def generate_key( + key_name: str = "author_key", + key_type: str = "ed25519", + output_dir: Optional[Path] = None, + progress_callback: Optional[Callable] = None +) -> Dict[str, Any]: + """ + Generates a new Ed25519 or RSA key pair for signing bundles. + + Args: + key_name: Base name for the generated key files + key_type: Type of key to generate ("ed25519" or "rsa") + output_dir: Directory to save keys (defaults to current directory) + progress_callback: Optional function for progress updates + + Returns: + Dict with structure: + { + "success": bool, + "private_key_path": str, + "public_key_path": str, + "openssh_public_key_path": str, + "key_type": str, + "fingerprint": str + } + + On error: + { + "success": false, + "error": "error message" + } + """ + def report_progress(message): + if progress_callback: + progress_callback(message) + else: + logging.info(message) + + try: + if output_dir is None: + output_dir = Path.cwd() + else: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Define key file paths + private_key_path = output_dir / f"{key_name}.pem" + public_key_path = output_dir / f"{key_name}.pub.pem" + openssh_public_key_path = output_dir / f"{key_name}.pub" + + # Check if files already exist + if private_key_path.exists() or public_key_path.exists() or openssh_public_key_path.exists(): + return { + "success": False, + "error": f"Key files already exist: {private_key_path.name}, {public_key_path.name}, or {openssh_public_key_path.name}" + } + + # Generate key based on requested type + key_type = key_type.lower() + if key_type == "ed25519": + report_progress("Generating Ed25519 key pair...") + private_key_obj = ed25519.Ed25519PrivateKey.generate() + elif key_type == "rsa": + report_progress("Generating RSA-2048 key pair...") + private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=2048) + else: + return { + "success": False, + "error": f"Unsupported key type: {key_type}. Supported types: ed25519, rsa" + } + + # Serialize private key to PEM + report_progress("Writing private key...") + priv_pem = private_key_obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + private_key_path.write_bytes(priv_pem) + + # Derive and serialize public key to PEM + report_progress("Writing public key...") + public_key_obj = private_key_obj.public_key() + pub_pem = public_key_obj.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + public_key_path.write_bytes(pub_pem) + + # Also write OpenSSH-compatible public key + openssh_pub = public_key_obj.public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH + ) + openssh_public_key_path.write_bytes(openssh_pub) + + # Generate fingerprint + fingerprint = _get_key_fingerprint(public_key_path) + + report_progress("Key generation completed successfully!") + + return { + "success": True, + "private_key_path": str(private_key_path.resolve()), + "public_key_path": str(public_key_path.resolve()), + "openssh_public_key_path": str(openssh_public_key_path.resolve()), + "key_type": key_type, + "fingerprint": fingerprint, + "message": f"Generated {key_type.upper()} key pair successfully" + } + + except Exception as e: + logging.exception(f"Unexpected error generating key") + return { + "success": False, + "error": f"Failed to generate key: {str(e)}" + } + + +def add_key( + key_path: Path, + progress_callback: Optional[Callable] = None +) -> Dict[str, Any]: + """ + Adds a single public key to the local trust store. + + Args: + key_path: Path to the public key file to add + progress_callback: Optional function for progress updates + + Returns: + Dict with structure: + { + "success": bool, + "key_name": str, + "fingerprint": str, + "destination_path": str, + "already_existed": bool + } + + On error: + { + "success": false, + "error": "error message" + } + """ + def report_progress(message): + if progress_callback: + progress_callback(message) + else: + logging.info(message) + + try: + key_path = Path(key_path) + + if not key_path.exists(): + return { + "success": False, + "error": f"Key file does not exist: {key_path}" + } + + if not key_path.is_file(): + return { + "success": False, + "error": f"Path is not a file: {key_path}" + } + + # Ensure manual keys directory exists + MANUAL_KEYS_DIR.mkdir(parents=True, exist_ok=True) + + destination = MANUAL_KEYS_DIR / key_path.name + already_existed = False + + if destination.exists(): + # Check if content is identical + if destination.read_bytes() == key_path.read_bytes(): + already_existed = True + report_progress(f"Key '{key_path.name}' already exists with identical content") + else: + return { + "success": False, + "error": f"Key '{key_path.name}' already exists with different content" + } + else: + report_progress(f"Adding key '{key_path.name}' to trust store...") + shutil.copy(key_path, destination) + + # Generate fingerprint + fingerprint = _get_key_fingerprint(destination) + + return { + "success": True, + "key_name": key_path.name, + "fingerprint": fingerprint, + "destination_path": str(destination), + "already_existed": already_existed, + "message": f"Key '{key_path.name}' {'already exists in' if already_existed else 'added to'} trust store" + } + + except Exception as e: + logging.exception(f"Unexpected error adding key") + return { + "success": False, + "error": f"Failed to add key: {str(e)}" + } + + +def remove_key( + key_identifier: str, + progress_callback: Optional[Callable] = None +) -> Dict[str, Any]: + """ + Removes a key from the trust store by fingerprint, name, or path. + + Args: + key_identifier: Key fingerprint, name, or path to identify the key to remove + progress_callback: Optional function for progress updates + + Returns: + Dict with structure: + { + "success": bool, + "removed_keys": [ + { + "name": str, + "path": str, + "source": str + } + ], + "removed_count": int + } + + On error: + { + "success": false, + "error": "error message" + } + """ + def report_progress(message): + if progress_callback: + progress_callback(message) + else: + logging.info(message) + + try: + if not LOCAL_TRUST_STORE_PATH.exists(): + return { + "success": False, + "error": "Trust store not found" + } + + removed_keys = [] + + # Search for matching keys (same patterns as list_keys) + for source_dir in LOCAL_TRUST_STORE_PATH.iterdir(): + if source_dir.is_dir(): + key_patterns = ["*.pub", "*.pub.pem"] + for pattern in key_patterns: + for key_file in source_dir.glob(pattern): + should_remove = False + + # Check if identifier matches fingerprint, name, or path + try: + fingerprint = _get_key_fingerprint(key_file) + if (key_identifier == fingerprint or + key_identifier == key_file.name or + key_identifier == str(key_file) or + key_identifier == str(key_file.resolve())): + should_remove = True + except Exception as e: + report_progress(f"Warning: Could not process key {key_file}: {e}") + continue + + if should_remove: + report_progress(f"Removing key '{key_file.name}' from source '{source_dir.name}'") + removed_keys.append({ + "name": key_file.name, + "path": str(key_file), + "source": source_dir.name + }) + key_file.unlink() + + if not removed_keys: + return { + "success": False, + "error": f"No matching key found for identifier: {key_identifier}" + } + + return { + "success": True, + "removed_keys": removed_keys, + "removed_count": len(removed_keys), + "message": f"Removed {len(removed_keys)} key(s)" + } + + except Exception as e: + logging.exception(f"Unexpected error removing key") + return { + "success": False, + "error": f"Failed to remove key: {str(e)}" + } + + +def sync_remotes(progress_callback: Optional[Callable] = None) -> Dict[str, Any]: + """ + Syncs the local trust store with all configured remote Git repositories. + + Returns: + Dict with structure: + { + "success": bool, + "synced_remotes": [ + { + "name": str, + "url": str, + "status": "success" | "failed", + "error": str (if failed) + } + ], + "removed_remotes": [str], # Names of remotes that were removed due to failures + "total_keys_synced": int + } + + On error: + { + "success": false, + "error": "error message" + } + """ + def report_progress(message): + if progress_callback: + progress_callback(message) + else: + logging.info(message) + + try: + if not KEY_REMOTES_CONFIG.exists(): + return { + "success": False, + "error": "No remotes configured. Use remote management to add remotes first." + } + + with open(KEY_REMOTES_CONFIG, 'r') as f: + config = json.load(f) + + remotes = config.get("remotes", []) + if not remotes: + return { + "success": False, + "error": "No remotes configured in config file" + } + + REMOTES_DIR.mkdir(parents=True, exist_ok=True) + + synced_remotes = [] + remotes_to_remove = [] + total_keys_synced = 0 + + for remote in remotes: + name = remote['name'] + url = remote['url'] + remote_path = REMOTES_DIR / name + + report_progress(f"Syncing remote '{name}' from {url}") + + try: + if remote_path.exists(): + run_command(["git", "pull"], cwd=remote_path) + else: + run_command(["git", "clone", "--depth", "1", url, str(remote_path)]) + + # Clean up non-public key files and count keys + keys_count = 0 + for item in remote_path.rglob("*"): + if item.is_file(): + if item.suffix == '.pub': + keys_count += 1 + else: + item.unlink() + + total_keys_synced += keys_count + + synced_remotes.append({ + "name": name, + "url": url, + "status": "success", + "keys_count": keys_count + }) + + report_progress(f"Successfully synced '{name}' ({keys_count} keys)") + + except Exception as e: + error_msg = str(e) + synced_remotes.append({ + "name": name, + "url": url, + "status": "failed", + "error": error_msg + }) + remotes_to_remove.append(name) + report_progress(f"Failed to sync remote '{name}': {error_msg}") + + # Remove failed remotes from config if any + if remotes_to_remove: + config['remotes'] = [r for r in config['remotes'] if r['name'] not in remotes_to_remove] + with open(KEY_REMOTES_CONFIG, 'w') as f: + json.dump(config, f, indent=2) + + success_count = len([r for r in synced_remotes if r["status"] == "success"]) + + return { + "success": True, + "synced_remotes": synced_remotes, + "removed_remotes": remotes_to_remove, + "total_keys_synced": total_keys_synced, + "message": f"Sync completed: {success_count} successful, {len(remotes_to_remove)} failed" + } + + except Exception as e: + logging.exception(f"Unexpected error syncing remotes") + return { + "success": False, + "error": f"Failed to sync remotes: {str(e)}" + } + + +def get_remote_sources(progress_callback: Optional[Callable] = None) -> Dict[str, Any]: + """ + Lists all configured remote key sources. + + Returns: + Dict with structure: + { + "success": bool, + "remotes": [ + { + "name": str, + "url": str, + "local_path": str, + "exists": bool, + "keys_count": int + } + ] + } + """ + try: + if not KEY_REMOTES_CONFIG.exists(): + return { + "success": True, + "remotes": [], + "message": "No remotes configured" + } + + with open(KEY_REMOTES_CONFIG, 'r') as f: + config = json.load(f) + + remotes_info = [] + for remote in config.get("remotes", []): + remote_path = REMOTES_DIR / remote['name'] + keys_count = len(list(remote_path.glob("*.pub"))) if remote_path.exists() else 0 + + remotes_info.append({ + "name": remote['name'], + "url": remote['url'], + "local_path": str(remote_path), + "exists": remote_path.exists(), + "keys_count": keys_count + }) + + return { + "success": True, + "remotes": remotes_info + } + + except Exception as e: + logging.exception(f"Unexpected error getting remote sources") + return { + "success": False, + "error": f"Failed to get remote sources: {str(e)}" + } + + +def add_remote_source( + name: str, + url: str, + progress_callback: Optional[Callable] = None +) -> Dict[str, Any]: + """ + Adds a new remote key source. + + Args: + name: Name for the remote source + url: Git repository URL + progress_callback: Optional function for progress updates + + Returns: + Dict with structure: + { + "success": bool, + "name": str, + "url": str, + "message": str + } + """ + try: + FOURDST_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + # Load existing config or create new one + config = {"remotes": []} + if KEY_REMOTES_CONFIG.exists(): + with open(KEY_REMOTES_CONFIG, 'r') as f: + config = json.load(f) + + # Check if remote already exists + for remote in config.get("remotes", []): + if remote['name'] == name: + return { + "success": False, + "error": f"Remote '{name}' already exists" + } + + # Add new remote + config.setdefault("remotes", []).append({ + "name": name, + "url": url + }) + + # Save config + with open(KEY_REMOTES_CONFIG, 'w') as f: + json.dump(config, f, indent=2) + + return { + "success": True, + "name": name, + "url": url, + "message": f"Remote '{name}' added successfully" + } + + except Exception as e: + logging.exception(f"Unexpected error adding remote source") + return { + "success": False, + "error": f"Failed to add remote source: {str(e)}" + } + + +def remove_remote_source( + name: str, + progress_callback: Optional[Callable] = None +) -> Dict[str, Any]: + """ + Removes a remote key source. + + Args: + name: Name of the remote source to remove + progress_callback: Optional function for progress updates + + Returns: + Dict with structure: + { + "success": bool, + "name": str, + "message": str + } + """ + try: + if not KEY_REMOTES_CONFIG.exists(): + return { + "success": False, + "error": "No remotes configured" + } + + with open(KEY_REMOTES_CONFIG, 'r') as f: + config = json.load(f) + + original_len = len(config.get("remotes", [])) + config["remotes"] = [r for r in config.get("remotes", []) if r['name'] != name] + + if len(config["remotes"]) == original_len: + return { + "success": False, + "error": f"Remote '{name}' not found" + } + + # Save updated config + with open(KEY_REMOTES_CONFIG, 'w') as f: + json.dump(config, f, indent=2) + + # Remove local directory if it exists + remote_path = REMOTES_DIR / name + if remote_path.exists(): + shutil.rmtree(remote_path) + + return { + "success": True, + "name": name, + "message": f"Remote '{name}' removed successfully" + } + + except Exception as e: + logging.exception(f"Unexpected error removing remote source") + return { + "success": False, + "error": f"Failed to remove remote source: {str(e)}" + } + + +def _get_key_fingerprint(key_path: Path) -> str: + """ + Generates a SHA256 fingerprint for a public key. + + Args: + key_path: Path to the public key file + + Returns: + SHA256 fingerprint in format "sha256:hexdigest" + """ + pub_key_bytes = key_path.read_bytes() + return "sha256:" + hashlib.sha256(pub_key_bytes).hexdigest()