feat(electron): added key managment ui

This commit is contained in:
2025-08-11 11:26:09 -04:00
parent d7d7615376
commit 268e4fbeae
28 changed files with 2525 additions and 247 deletions

View File

@@ -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'
)

View File

@@ -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)

View File

@@ -30,6 +30,10 @@
<div class="category-icon" style="background-color: #10b981;">LC</div>
<span class="category-label">libconstants</span>
</div>
<div class="category-item" data-category="keys" title="Key Management">
<div class="category-icon" style="background-color: #8b5cf6;">KM</div>
<span class="category-label">Key Management</span>
</div>
<div class="category-item" data-category="opat" title="OPAT Core">
<div class="category-icon" style="background-color: #f59e0b;">OC</div>
<span class="category-label">OPAT Core</span>
@@ -71,6 +75,19 @@
</div>
</div>
<!-- Key Management content -->
<div class="sidebar-content hidden" data-category="keys">
<div class="sidebar-header">
<h3>Key Management</h3>
</div>
<nav class="sidebar-nav">
<button id="keys-list-btn" class="nav-button active">Trusted Keys</button>
<button id="keys-generate-btn" class="nav-button">Generate Key</button>
<button id="keys-add-btn" class="nav-button">Add Key</button>
<button id="keys-remotes-btn" class="nav-button">Remote Sources</button>
</nav>
</div>
<!-- OPAT Core content -->
<div class="sidebar-content hidden" data-category="opat">
<div class="sidebar-header">
@@ -126,6 +143,14 @@
</div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background-color: #8b5cf6;">KM</div>
<div class="feature-info">
<h3>Key Management</h3>
<p>Manage cryptographic keys for bundle signing and verification.</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background-color: #10b981;">LC</div>
<div class="feature-info">
@@ -455,6 +480,118 @@
</div>
</div>
<!-- Key Management Views -->
<div id="keys-view" class="hidden">
<!-- Trusted Keys View -->
<div id="keys-list-view" class="key-management-view">
<div class="keys-header">
<h3>Trusted Keys</h3>
<p>Manage cryptographic keys for bundle signing and verification.</p>
</div>
<div id="keys-list-container">
<div class="empty-state">
<div class="empty-icon">🔑</div>
<h3>Loading Keys...</h3>
<p>Please wait while we load your trusted keys.</p>
</div>
</div>
</div>
<!-- Generate Key View -->
<div id="keys-generate-view" class="key-management-view hidden">
<div class="generate-key-header">
<h3>Generate New Key Pair</h3>
<p>Create a new cryptographic key pair for signing bundles.</p>
</div>
<div class="generate-key-form">
<div class="form-group">
<label for="generate-key-name">Key Name:</label>
<input type="text" id="generate-key-name" placeholder="author_key" value="author_key">
<small>Base name for the generated key files</small>
</div>
<div class="form-group">
<label for="generate-key-type">Key Type:</label>
<select id="generate-key-type">
<option value="ed25519">Ed25519 (Recommended)</option>
<option value="rsa">RSA-2048</option>
</select>
<small>Ed25519 is faster and more secure than RSA</small>
</div>
<div class="form-group">
<label for="generate-output-dir">Output Directory:</label>
<input type="text" id="generate-output-dir" placeholder="." value=".">
<small>Directory where keys will be saved</small>
</div>
<div class="form-actions">
<button id="generate-key-btn" class="action-button primary">Generate Key Pair</button>
</div>
<div class="security-notice">
<div class="warning-banner">
<span class="warning-icon">⚠️</span>
<div class="warning-text">
<strong>Security Notice:</strong> Keep your private key secure and never share it.
Only share the public key (.pub or .pub.pem files) with others who need to verify your bundles.
</div>
</div>
</div>
</div>
</div>
<!-- Add Key View -->
<div id="keys-add-view" class="key-management-view hidden">
<div class="add-key-header">
<h3>Add Trusted Key</h3>
<p>Add a public key to your trust store for bundle verification.</p>
</div>
<div class="add-key-form">
<div class="form-actions">
<button id="add-key-file-btn" class="action-button primary">Select Key File</button>
</div>
<div class="info-notice">
<div class="info-banner">
<span class="info-icon"></span>
<div class="info-text">
<strong>Supported formats:</strong> .pub, .pub.pem, and .pem files containing public keys.
</div>
</div>
</div>
</div>
</div>
<!-- Remote Sources View -->
<div id="keys-remotes-view" class="key-management-view hidden">
<div class="remotes-header">
<h3>Remote Key Sources</h3>
<p>Manage Git repositories that contain trusted public keys.</p>
</div>
<div class="add-remote-form">
<h4>Add New Remote Source</h4>
<div class="form-row">
<div class="form-group">
<label for="remote-name">Name:</label>
<input type="text" id="remote-name" placeholder="my-keys">
</div>
<div class="form-group">
<label for="remote-url">Git Repository URL:</label>
<input type="text" id="remote-url" placeholder="https://github.com/user/keys.git">
</div>
<div class="form-group">
<button id="add-remote-btn" class="action-button primary">Add Remote</button>
</div>
</div>
</div>
<div id="remotes-list-container">
<div class="empty-state">
<div class="empty-icon">🌐</div>
<h3>Loading Remote Sources...</h3>
<p>Please wait while we load your remote key sources.</p>
</div>
</div>
</div>
</div>
<div id="create-bundle-form" class="hidden">
<!-- The create form will be moved into a modal later -->
</div>

View File

@@ -60,7 +60,7 @@
<p>This installer will install the 4DSTAR Bundle Manager, a comprehensive tool for managing 4DSTAR plugin bundles and OPAT data files.</p>
<div class="highlight">
<div>
<strong>What's Included:</strong>
<ul>
<li>4DSTAR Bundle Manager application</li>

View File

@@ -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');
}

View File

@@ -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) => {

View File

@@ -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({

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 = `
<div class="empty-state">
<div class="empty-icon">🔑</div>
<h3>No Keys Found</h3>
<p>No trusted keys are currently installed. Generate or add keys to get started.</p>
</div>
`;
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 = `
<button id="refresh-keys-btn" class="action-button secondary">
<span class="icon">🔄</span> Refresh
</button>
<button id="sync-remotes-btn" class="action-button primary">
<span class="icon">🔄</span> Sync Remotes
</button>
`;
keysHeader.appendChild(actionsDiv);
}
let html = '';
for (const [sourceName, keys] of Object.entries(keysData.keys)) {
html += `
<div class="key-source-section">
<div class="source-header">
<h4>${sourceName}</h4>
<span class="key-count">${keys.length} keys</span>
</div>
<div class="keys-table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Fingerprint</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const key of keys) {
const shortFingerprint = key.fingerprint.substring(0, 16) + '...';
html += `
<tr data-key-fingerprint="${key.fingerprint}">
<td class="key-name">${key.name}</td>
<td class="key-fingerprint" title="${key.fingerprint}">${shortFingerprint}</td>
<td class="key-size">${key.size_bytes} bytes</td>
<td class="key-actions">
<button class="btn btn-small btn-danger remove-key-btn"
data-key-fingerprint="${key.fingerprint}"
data-key-name="${key.name}">
Remove
</button>
</td>
</tr>
`;
}
html += `
</tbody>
</table>
</div>
</div>
`;
}
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', `
<div class="key-generation-success">
<h4>Key Generated Successfully!</h4>
<div class="key-details">
<p><strong>Key Type:</strong> ${result.key_type.toUpperCase()}</p>
<p><strong>Fingerprint:</strong> <code>${result.fingerprint}</code></p>
<p><strong>Private Key:</strong> <code>${result.private_key_path}</code></p>
<p><strong>Public Key:</strong> <code>${result.public_key_path}</code></p>
</div>
<div class="warning">
<strong>⚠️ Important:</strong> Keep your private key secure and never share it!
</div>
</div>
`);
// 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', `
<div class="key-add-success-modern">
<div class="success-header">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#10b981" stroke="#059669" stroke-width="2"/>
<path d="m9 12 2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3>Key Added Successfully!</h3>
<p class="success-subtitle">Your public key has been added to the trust store</p>
</div>
<div class="key-details-card">
<div class="detail-row">
<div class="detail-label">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Key Name
</div>
<div class="detail-value">${addResult.key_name}</div>
</div>
<div class="detail-row">
<div class="detail-label">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z" stroke="currentColor" stroke-width="2"/>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2"/>
</svg>
Fingerprint
</div>
<div class="detail-value fingerprint-value">
<code>${addResult.fingerprint}</code>
<button class="copy-btn" onclick="navigator.clipboard.writeText('${addResult.fingerprint}'); this.innerHTML='✓ Copied'; setTimeout(() => this.innerHTML='Copy', 2000)">Copy</button>
</div>
</div>
</div>
<div class="success-footer">
<div class="info-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="m9 9 1.5 1.5L16 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>This key can now be used to verify signed bundles</span>
</div>
</div>
</div>
`);
}
// 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 = `
<div class="sync-results">
<h4>Remote Sync Completed</h4>
<p>✅ Successful: ${successCount}</p>
<p>❌ Failed: ${failedCount}</p>
<p>📦 Total keys synced: ${result.total_keys_synced}</p>
</div>
`;
if (result.removed_remotes.length > 0) {
message += `<p><strong>Removed failing remotes:</strong> ${result.removed_remotes.join(', ')}</p>`;
}
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 = `
<div class="empty-state">
<div class="empty-icon">🌐</div>
<h3>No Remote Sources</h3>
<p>No remote key sources are configured. Add remote repositories to sync keys automatically.</p>
</div>
`;
return;
}
let html = `
<div class="remotes-header">
<h3>Remote Key Sources (${remotes.length})</h3>
</div>
<div class="remotes-table">
<table>
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>URL</th>
<th>Keys</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const remote of remotes) {
const status = remote.exists ? '✅' : '❌';
const statusText = remote.exists ? 'Synced' : 'Not synced';
html += `
<tr>
<td class="remote-status" title="${statusText}">${status}</td>
<td class="remote-name">${remote.name}</td>
<td class="remote-url" title="${remote.url}">${remote.url}</td>
<td class="remote-keys">${remote.keys_count}</td>
<td class="remote-actions">
<button class="btn btn-small btn-danger remove-remote-btn"
data-remote-name="${remote.name}">
Remove
</button>
</td>
</tr>
`;
}
html += `
</tbody>
</table>
</div>
`;
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
};

View File

@@ -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,

View File

@@ -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
};

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEINS0UV/JuxLOW7PyegagGJfCP13oVrBXXBhMBRV0YK5+
-----END PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILXntMn/iLZI5hHaU+b9WYzE8E4D04gseoRbexZeIbrU

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAtee0yf+ItkjmEdpT5v1ZjMTwTgPTiCx6hFt7Fl4hutQ=
-----END PUBLIC KEY-----

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

746
fourdst/core/keys.py Normal file
View File

@@ -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()