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
+
OC
OPAT Core
@@ -71,6 +75,19 @@
+
+
+
+
+
KM
+
+
Key Management
+
Manage cryptographic keys for bundle signing and verification.
+
+
+
+
+
+
+
+
+
+
+
š
+
Loading Keys...
+
Please wait while we load your trusted keys.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
š
+
Loading Remote Sources...
+
Please wait while we load your remote key sources.
+
+
+
+
+
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 = `
+
+ š Refresh
+
+
+ š Sync Remotes
+
+ `;
+ keysHeader.appendChild(actionsDiv);
+ }
+
+ let html = '';
+
+ for (const [sourceName, keys] of Object.entries(keysData.keys)) {
+ html += `
+
+
+
+
+
+
+ Name
+ Fingerprint
+ Size
+ Actions
+
+
+
+ `;
+
+ for (const key of keys) {
+ const shortFingerprint = key.fingerprint.substring(0, 16) + '...';
+ html += `
+
+ ${key.name}
+ ${shortFingerprint}
+ ${key.size_bytes} bytes
+
+
+ Remove
+
+
+
+ `;
+ }
+
+ html += `
+
+
+
+
+ `;
+ }
+
+ 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', `
+
+
+
+
+
+
+
${addResult.key_name}
+
+
+
+
+
+ ${addResult.fingerprint}
+ Copy
+
+
+
+
+
+
+ `);
+ }
+
+ // 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 = `
+
+
+
+
+
+ Status
+ Name
+ URL
+ Keys
+ Actions
+
+
+
+ `;
+
+ for (const remote of remotes) {
+ const status = remote.exists ? 'ā
' : 'ā';
+ const statusText = remote.exists ? 'Synced' : 'Not synced';
+
+ html += `
+
+ ${status}
+ ${remote.name}
+ ${remote.url}
+ ${remote.keys_count}
+
+
+ Remove
+
+
+
+ `;
+ }
+
+ html += `
+
+
+
+ `;
+
+ 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()