v0.2.6-pre.012

This commit is contained in:
2026-08-21 16:22:16 +02:00
parent 8ba554306a
commit ecbd6b746d
21 changed files with 634 additions and 94 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 181
# version: 182
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package]
version = "0.2.6-pre.11"
version = "0.2.6-pre.12"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -42,7 +42,7 @@
<h1 id="viewTitle" class="h3 mb-1">Dashboard</h1>
<p class="text-body-secondary mb-0">Identité autorisée et balance SOL via le Transport HTTP composite sont actives.</p>
</div>
<span class="badge text-bg-secondary">0.2.6-pre.008</span>
<span class="badge text-bg-secondary">0.2.6-pre.012</span>
</div>
<section data-view-panel="dashboard">
<div class="row g-3">
@@ -380,6 +380,27 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-danger-subtle">
<div class="card-header fw-semibold">Export keypair Solana — OWNER uniquement</div>
<div class="card-body">
<p class="small text-body-secondary">Lexport produit un fichier secret non chiffré contenant la keypair Solana complète. Le contenu et le chemin complet restent exclusivement côté Rust ; le frontend choisit seulement le format puis confirme lopération.</p>
<div class="row g-3 align-items-end">
<div class="col-lg-6">
<label class="form-label" for="walletExportFormat">Format dexport</label>
<select id="walletExportFormat" class="form-select" data-owner-export-control disabled>
<option value="solana_cli_json">Solana CLI JSON</option>
<option value="solana_keypair_base58">Keypair Base58 complet</option>
</select>
</div>
<div class="col-lg-6 d-grid">
<button id="exportWalletKeypair" class="btn btn-outline-danger" type="button" data-owner-export-control disabled>Export secret keypair file</button>
</div>
</div>
<div id="walletExportStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session OWNER requise. Aucun export texte ou clipboard nest disponible.</div>
</div>
</div>
</div>
<div class="col-12">
<div id="unlockStatus" class="alert alert-secondary mb-0" role="status">Sélectionner un wallet verrouillé.</div>
</div>
@@ -443,8 +464,23 @@
</div>
</div>
</div>
<div class="modal fade" id="exportWalletKeypairModal" tabindex="-1" aria-labelledby="exportWalletKeypairModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="exportWalletKeypairModalLabel">Exporter la keypair Solana ?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">Cette opération OWNER crée un fichier secret non chiffré contenant les 64 octets de la keypair Solana. Le fichier doit être protégé comme une clé privée. Un fichier existant ne sera jamais écrasé par Wallet.</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button id="confirmExportWalletKeypair" type="button" class="btn btn-danger">Choose destination and export</button>
</div>
</div>
</div>
</div>
<footer class="app-footer bg-dark text-white-50 d-flex align-items-center px-4">
<span>KSP Wallet Desk · strong VIEW administration pre.011</span>
<span>KSP Wallet Desk · OWNER keypair export pre.012</span>
</footer>
<script type="module" src="./ts/main.ts"></script>
</body>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/frontend/ts/main.ts
// version: 12
// version: 13
import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5";
@@ -10,9 +10,11 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import type { WalletBalanceDto } from "./bindings/ksp_app_wallet_desk/wallet_balance/WalletBalanceDto.ts";
import type { CommandErrorDto } from "./bindings/ksp_app_wallet_desk/dto_common/CommandErrorDto.ts";
import type { RuntimeStatusDto } from "./bindings/ksp_app_wallet_desk/dto_common/RuntimeStatusDto.ts";
import type { WalletExportRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_export/WalletExportRequestDto.ts";
import type { WalletExportResultDto } from "./bindings/ksp_app_wallet_desk/wallet_export/WalletExportResultDto.ts";
import type { WalletImportRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportRequestDto.ts";
import type { WalletImportSourceRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletImportSourceRequestDto.ts";
import type { WalletTransferFormatDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletTransferFormatDto.ts";
import type { WalletTransferFormatDto } from "./bindings/ksp_app_wallet_desk/wallet_transfer/WalletTransferFormatDto.ts";
import type { WalletTransferInspectionDto } from "./bindings/ksp_app_wallet_desk/wallet_import/WalletTransferInspectionDto.ts";
import type { LockedWalletDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/LockedWalletDto.ts";
import type { WalletInventoryEntryDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryEntryDto.ts";
@@ -54,6 +56,7 @@ let authorizedWalletProjection: WalletAuthorizedDto | null = null;
let configuredSecretCandidateCount = 0;
let deleteNoteModal: Modal | null = null;
let disableViewModal: Modal | null = null;
let exportWalletModal: Modal | null = null;
let recreateViewModal: Modal | null = null;
let pendingDeleteNoteId: string | null = null;
let stagedImportSource: WalletTransferInspectionDto | null = null;
@@ -187,7 +190,7 @@ function initializeWalletTable(): void {
zeroRecords: "Aucun wallet correspondant.",
},
});
frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.011-strong-view-administration" });
frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.012-owner-transfer-export" });
}
function renderWalletInventory(entries: WalletInventoryEntryDto[]): void {
@@ -227,6 +230,7 @@ function updateSessionActions(): void {
updateOwnerMetadataActions();
updateSecurityRotationActions();
updateStrongViewSecurityActions();
updateWalletExportActions();
}
function updateOwnerMetadataActions(): void {
@@ -259,6 +263,13 @@ function updateStrongViewSecurityActions(): void {
});
}
function updateWalletExportActions(): void {
const ownerOpen = activeSessionState === "owner_open" && authorizedWalletProjection?.capability === "owner";
document.querySelectorAll<HTMLSelectElement | HTMLButtonElement>("[data-owner-export-control]").forEach(control => {
control.disabled = !ownerOpen;
});
}
function updateUnlockActions(): void {
const locked = activeSessionState === "locked";
const viewManual = document.querySelector<HTMLButtonElement>("#unlockViewManual");
@@ -392,6 +403,7 @@ function clearAuthorizedProjection(): void {
clearUnlockSensitiveInputs();
setText("#rotationStatus", "Session OWNER requise.");
setText("#viewSecurityStatus", "Session OWNER requise pour administrer fortement VIEW.");
setText("#walletExportStatus", "Session OWNER requise. Aucun export texte ou clipboard nest disponible.");
}
function clearSelectedWallet(): void {
@@ -430,6 +442,7 @@ function renderLockedWallet(wallet: LockedWalletDto): void {
setText("#currentWalletSecretCandidates", configuredSecretCandidateCount.toString());
setText("#unlockStatus", "Prêt pour une tentative explicite. Argon2 peut prendre plusieurs secondes.");
setText("#viewSecurityStatus", "Unlock OWNER requis pour administrer fortement VIEW.");
setText("#walletExportStatus", "Unlock OWNER requis pour exporter la keypair Solana.");
updateSessionActions();
frontendDebug("main", "Locked Wallet selection rendered", {
configuredSecretCandidateCount,
@@ -481,6 +494,12 @@ function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
: "OWNER ouvert : VIEW disabled. Strong recreate disponible."
: "VIEW ouvert : ladministration forte VIEW est réservée à OWNER.",
);
setText(
"#walletExportStatus",
wallet.capability === "owner"
? "OWNER ouvert : export fichier Solana CLI JSON ou Base58 disponible via save picker natif."
: "VIEW ouvert : export de keypair interdit ; OWNER requis.",
);
updateSessionActions();
frontendDebug("main", "Authorized Wallet session rendered", {
capability: wallet.capability,
@@ -1244,6 +1263,67 @@ function bindStrongViewSecurityActions(): void {
frontendTrace("main", "Strong VIEW security action handlers installed");
}
async function runWalletExport(walletId: string, format: WalletTransferFormatDto): Promise<void> {
activeSessionState = "privileged_operation";
setText("#currentWalletState", "OWNER export…");
setText("#walletExportStatus", "Save picker natif / export secret en cours…");
updateSessionActions();
frontendDebug("main", "Wallet OWNER transfer export requested", { format, walletId });
const request: WalletExportRequestDto = { format };
try {
const result = await invokeKsp<WalletExportResultDto | null>("main", "export_wallet_transfer", { request });
if (activeWalletId !== walletId || authorizedWalletProjection?.capability !== "owner") {
frontendWarn("main", "Stale Wallet export response ignored", { format, walletId });
return;
}
activeSessionState = "owner_open";
setText("#currentWalletState", "OWNER open");
if (result === null) {
setText("#walletExportStatus", "Export annulé dans le save picker natif ; aucun fichier créé.");
updateSessionActions();
frontendDebug("main", "Wallet OWNER transfer export cancelled", { format, walletId });
return;
}
setText("#walletExportStatus", `Export ${importFormatLabel(result.format)} créé : ${result.destinationName}. Le chemin complet et le contenu secret nont pas traversé IPC.`);
updateSessionActions();
frontendInfo("main", "Wallet OWNER transfer export completed", { format: result.format, walletId });
} catch {
if (activeWalletId === walletId && authorizedWalletProjection?.capability === "owner") {
activeSessionState = "owner_open";
setText("#currentWalletState", "OWNER open");
setText("#walletExportStatus", "Export refusé ou impossible ; aucun écrasement nest autorisé et la session OWNER reste ouverte.");
updateSessionActions();
}
frontendWarn("main", "Wallet OWNER transfer export failed", { format, walletId });
}
}
function bindWalletExportActions(): void {
const modalElement = document.querySelector<HTMLElement>("#exportWalletKeypairModal");
if (modalElement) {
exportWalletModal = new Modal(modalElement);
}
document.querySelector<HTMLButtonElement>("#exportWalletKeypair")?.addEventListener("click", () => {
if (!activeWalletId || activeSessionState !== "owner_open" || authorizedWalletProjection?.capability !== "owner") {
setText("#walletExportStatus", "Session OWNER requise.");
return;
}
frontendDebug("main", "Wallet OWNER transfer export confirmation opened", { walletId: activeWalletId });
exportWalletModal?.show();
});
document.querySelector<HTMLButtonElement>("#confirmExportWalletKeypair")?.addEventListener("click", () => {
const walletId = activeWalletId;
const formatControl = document.querySelector<HTMLSelectElement>("#walletExportFormat");
exportWalletModal?.hide();
if (!walletId || activeSessionState !== "owner_open" || authorizedWalletProjection?.capability !== "owner" || !formatControl) {
return;
}
const format = formatControl.value as WalletTransferFormatDto;
void runWalletExport(walletId, format);
});
frontendTrace("main", "Wallet OWNER transfer export handlers installed");
}
function bindRotationActions(): void {
document.querySelector<HTMLButtonElement>("#rotateOwnerPasswordSubmit")?.addEventListener("click", () => {
void rotateWalletPassword("owner");
@@ -1357,7 +1437,7 @@ function renderRuntimeStatus(status: RuntimeStatusDto): void {
setText("#runtimeEffectiveWalletsDirectory", status.effectiveWalletsDirectory);
setText("#runtimeWalletDirectoryCreated", status.effectiveWalletsDirectoryCreatedOnStartup ? "oui" : "non, déjà présent");
setText("#runtimeShellPhase", status.shellPhase);
setText("#shellStatus", "Config/Transport résolus ; import, VIEW/OWNER, metadata, rotations, strong VIEW et getBalance prêts.");
setText("#shellStatus", "Config/Transport résolus ; import/export, VIEW/OWNER, metadata, rotations, strong VIEW et getBalance prêts.");
frontendTrace("main", "Wallet Desk runtime status rendered", {
compositeProfile: status.activeCompositeProfile,
fallbackLoggingActive: status.fallbackLoggingActive,
@@ -1411,6 +1491,7 @@ async function initializeMain(): Promise<void> {
bindOwnerMetadataActions();
bindRotationActions();
bindStrongViewSecurityActions();
bindWalletExportActions();
bindShellActions();
clearSelectedWallet();
activateView("dashboard", "startup");

View File

@@ -1,7 +1,7 @@
{
"name": "ksp-app-wallet-desk",
"private": true,
"version": "0.2.6-pre.11",
"version": "0.2.6-pre.12",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 15
// version: 16
//! Shared backend state owned by the Wallet Desk Tauri application.
@@ -126,7 +126,7 @@ impl AppState {
effective_wallets_directory_created_on_startup: self.wallet_config_startup.effective_directory_created_on_startup(),
fallback_logging_active: runtime.fallback_active,
root_wallets_directory_created_on_startup: self.wallet_config_startup.root_directory_created_on_startup(),
shell_phase: "pre.010-credential-rotation".to_owned(),
shell_phase: "pre.012-owner-transfer-export".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
transport_available_endpoint_count,
transport_clusters: self.transport_runtime.clusters(),
@@ -722,6 +722,71 @@ impl AppState {
return self.finish_owner_operation(context, result).await;
}
/// Returns the native save-picker default filename when an OWNER session is currently authorized.
pub(crate) fn wallet_export_picker_filename(&self, format: crate::WalletTransferFormatDto) -> ksp_core_lib::Result<String> {
let session = self.wallet_session.lock();
let session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
return match &*session {
crate::WalletSession::Owner { wallet_id, .. } => std::result::Result::Ok(crate::wallet_export_default_filename(wallet_id.as_str(), format)),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet transfer export requires an authorized OWNER session",
)),
};
}
/// Exports the immutable Solana keypair to one Rust-owned native save-picker destination.
pub(crate) async fn export_wallet_transfer(
&self,
destination: std::path::PathBuf,
request: crate::WalletExportRequestDto,
) -> ksp_core_lib::Result<crate::WalletExportResultDto> {
let destination_name = crate::wallet_export_destination_name(destination.as_path());
let destination_name = match destination_name {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let format = request.format;
let context = self.begin_owner_operation("keypair_export");
let context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wallet_id = context.wallet_id.clone();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
"Wallet OWNER transfer export started"
);
let result = context.owner.export_transfer_file(destination.as_path(), format.wallet_format()).await;
let restored = self.finish_owner_operation(context, result).await;
if let std::result::Result::Err(error) = restored {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
error_domain = error.code().domain(),
error_code = error.code().code(),
"Wallet OWNER transfer export failed"
);
return std::result::Result::Err(error);
}
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
"Wallet OWNER transfer export completed"
);
return std::result::Result::Ok(crate::WalletExportResultDto { destination_name, format, wallet_id });
}
/// Strongly disables VIEW from the current OWNER session while preserving OWNER authorization.
pub(crate) async fn disable_wallet_view(&self) -> ksp_core_lib::Result<crate::WalletViewSecurityStatusDto> {
let context = self.begin_owner_operation("view_disable");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/constants.rs
// version: 7
// version: 8
//! Logging targets, domains and composite component identifiers owned by Wallet Desk.
@@ -19,6 +19,8 @@ pub(crate) const TRACING_DOMAIN_SHELL: &str = "wallet.shell";
pub(crate) const TRACING_DOMAIN_TRANSPORT: &str = "wallet.transport";
/// Structured domain used while preparing Wallet filesystem roots.
pub(crate) const TRACING_DOMAIN_WALLET_CONFIG: &str = "wallet.config";
/// Structured domain used by native-save-picker OWNER transfer export operations.
pub(crate) const TRACING_DOMAIN_WALLET_EXPORT: &str = "wallet.export";
/// Structured domain used by native-picker Wallet transfer import operations.
pub(crate) const TRACING_DOMAIN_WALLET_IMPORT: &str = "wallet.import";
/// Structured domain used while enumerating and inspecting locked Wallet files.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/errors.rs
// version: 7
// version: 8
//! Application-local error codes for Wallet Desk composition and desktop runtime surfaces.
@@ -34,6 +34,9 @@ pub(crate) const ERROR_CODE_WALLET_DIRECTORY_INVALID: ksp_core_lib::ErrorCode =
/// Wallet Desk could not inspect or create the configured Wallet directory tree.
pub(crate) const ERROR_CODE_WALLET_DIRECTORY_PREPARE_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_directory_prepare_failed");
/// A native save-picker Wallet export destination is not a usable local filesystem path.
pub(crate) const ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_export_destination_invalid");
/// A native-picker Wallet transfer source is not a supported regular file or cannot be staged safely.
pub(crate) const ERROR_CODE_WALLET_IMPORT_SOURCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_import_source_invalid");
/// Wallet import was requested without one previously inspected native-picker source.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 12
// version: 13
//! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -21,12 +21,14 @@ mod tw_main;
mod tw_splash;
mod wallet_balance;
mod wallet_config;
mod wallet_export;
mod wallet_import;
mod wallet_inventory;
mod wallet_metadata;
mod wallet_secrets;
mod wallet_security;
mod wallet_session;
mod wallet_transfer;
/// Runs the KSP wallet desktop application.
pub use self::tauri::run;
@@ -59,6 +61,8 @@ pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
pub(crate) use self::constants::TRACING_DOMAIN_TRANSPORT;
/// Structured domain used while preparing Wallet filesystem roots.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_CONFIG;
/// Structured domain used by native-save-picker OWNER transfer export operations.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_EXPORT;
/// Structured domain used by native-picker Wallet transfer import operations.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_IMPORT;
/// Structured domain used while enumerating and inspecting locked Wallet files.
@@ -111,6 +115,8 @@ pub(crate) use self::errors::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED;
pub(crate) use self::errors::ERROR_CODE_WALLET_DIRECTORY_INVALID;
/// Wallet Desk could not inspect or create the configured Wallet directory tree.
pub(crate) use self::errors::ERROR_CODE_WALLET_DIRECTORY_PREPARE_FAILED;
/// Native save-picker Wallet export destination is invalid.
pub(crate) use self::errors::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID;
/// Native-picker transfer source is invalid or cannot be staged.
pub(crate) use self::errors::ERROR_CODE_WALLET_IMPORT_SOURCE_INVALID;
/// Wallet import has no staged native-picker source.
@@ -155,14 +161,20 @@ pub(crate) use self::wallet_balance::format_lamports_as_sol;
pub(crate) use self::wallet_config::WalletConfigStartup;
/// Resolves the composite-selected Wallet Config and prepares its application-owned directory tree.
pub(crate) use self::wallet_config::initialize_wallet_config;
/// OWNER transfer export request carrying only the format selected by the frontend.
pub(crate) use self::wallet_export::WalletExportRequestDto;
/// Safe OWNER transfer export result without path or key material.
pub(crate) use self::wallet_export::WalletExportResultDto;
/// Builds the deterministic native save-picker filename for the current OWNER Wallet.
pub(crate) use self::wallet_export::wallet_export_default_filename;
/// Extracts a safe basename from the Rust-only native save-picker destination.
pub(crate) use self::wallet_export::wallet_export_destination_name;
/// Rust-only staged import source containing bounded zeroizing transfer bytes.
pub(crate) use self::wallet_import::PendingWalletImport;
/// Import request whose new native Wallet credentials move frontend -> Rust only.
pub(crate) use self::wallet_import::WalletImportRequestDto;
/// Native picker request containing only the expected transfer format.
pub(crate) use self::wallet_import::WalletImportSourceRequestDto;
/// Safe transfer format DTO used by the import picker.
pub(crate) use self::wallet_import::WalletTransferFormatDto;
/// Safe inspection projection for one staged external transfer source.
pub(crate) use self::wallet_import::WalletTransferInspectionDto;
/// Loads and validates one native-picker import source into bounded zeroizing Rust memory.
@@ -225,3 +237,5 @@ pub(crate) use self::wallet_session::WalletUnlockRequestDto;
pub(crate) use self::wallet_session::owner_projection;
/// Builds an authorized VIEW DTO from a Rust-only Wallet handle.
pub(crate) use self::wallet_session::view_projection;
/// Shared import/export transfer format selected by Wallet Desk.
pub(crate) use self::wallet_transfer::WalletTransferFormatDto;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/tauri.rs
// version: 9
// version: 10
//! Tauri runtime assembly for the KSP wallet desktop application.
@@ -47,6 +47,7 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
deselect_wallet,
disable_wallet_view,
emit_frontend_log,
export_wallet_transfer,
get_runtime_status,
import_wallet,
inspect_import_source,
@@ -155,6 +156,51 @@ fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Resu
};
}
#[tauri::command]
async fn export_wallet_transfer(
app: tauri::AppHandle,
request: crate::WalletExportRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::option::Option<crate::WalletExportResultDto>, crate::CommandErrorDto> {
let format = request.format;
let default_filename = state.wallet_export_picker_filename(format);
let default_filename = match default_filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
let extensions = [format.default_extension()];
let selected = app
.dialog()
.file()
.set_title("Export Solana keypair — secret material")
.set_file_name(default_filename)
.add_filter("Solana keypair transfer", &extensions)
.blocking_save_file();
let selected = match selected {
std::option::Option::Some(value) => value,
std::option::Option::None => {
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_EXPORT, transfer_format = format.code(), "Wallet OWNER transfer export native save picker cancelled");
return std::result::Result::Ok(std::option::Option::None);
},
};
let path = selected.into_path();
let path = match path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
let error = ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID,
"Selected Wallet transfer export destination is not a local filesystem path",
);
return std::result::Result::Err(crate::CommandErrorDto::from_error(&error));
},
};
let result = state.export_wallet_transfer(path, request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::RuntimeStatusDto, crate::CommandErrorDto> {
let result = state.runtime_status();

View File

@@ -0,0 +1,52 @@
// file: crates/ksp-app-wallet-desk/src/wallet_export.rs
// version: 1
//! OWNER-only native save-picker Wallet transfer export contracts.
use ts_rs::TS; // rust-rules: trait-import
/// OWNER export request containing only the explicit transfer format.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_export/WalletExportRequestDto.ts")]
pub(crate) struct WalletExportRequestDto {
/// Secret-transfer encoding selected before the Rust-owned save picker opens.
pub(crate) format: crate::WalletTransferFormatDto,
}
/// Safe result returned after an OWNER keypair export completes.
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_export/WalletExportResultDto.ts")]
pub(crate) struct WalletExportResultDto {
/// Basename only for the newly created export file; the parent path remains Rust-only.
pub(crate) destination_name: String,
/// Explicit transfer format used by the export.
pub(crate) format: crate::WalletTransferFormatDto,
/// Root-scoped native Wallet identifier that authorized the export.
pub(crate) wallet_id: String,
}
/// Builds a deterministic native-save default filename without exposing Wallet metadata or Pubkey.
#[must_use]
pub(crate) fn wallet_export_default_filename(wallet_id: &str, format: crate::WalletTransferFormatDto) -> String {
let stem = wallet_id.strip_suffix(crate::WALLET_FILE_SUFFIX).unwrap_or(wallet_id);
let stem = if stem.is_empty() { "wallet" } else { stem };
return format!("{stem}-solana-keypair.{}", format.default_extension());
}
/// Extracts one safe basename from a native save-picker destination.
pub(crate) fn wallet_export_destination_name(destination: &std::path::Path) -> ksp_core_lib::Result<String> {
let name = destination.file_name().and_then(std::ffi::OsStr::to_str);
return match name {
std::option::Option::Some(value) if !value.is_empty() => std::result::Result::Ok(value.to_owned()),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID,
"Selected Wallet export destination has no usable local filename",
)),
};
}
#[cfg(test)]
#[path = "../unit_tests/wallet_export.rs"]
mod tests;

View File

@@ -1,65 +1,18 @@
// file: crates/ksp-app-wallet-desk/src/wallet_import.rs
// version: 2
// version: 3
//! Native-picker transfer import staging with bounded secret material kept only in Rust.
use tokio::io::AsyncReadExt; // rust-rules: trait-import
use ts_rs::TS; // rust-rules: trait-import
/// Transfer format selected before opening the native import picker.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletTransferFormatDto.ts")]
pub(crate) enum WalletTransferFormatDto {
/// Standard Solana CLI keypair JSON array containing 64 byte values.
SolanaCliJson,
/// Canonical Base58 encoding of the complete 64-byte Solana keypair.
SolanaKeypairBase58,
}
impl WalletTransferFormatDto {
/// Maps the desktop DTO to the stable Wallet transfer format.
#[must_use]
pub(crate) const fn wallet_format(self) -> ksp_wallet_lib::WalletTransferFormat {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::WalletTransferFormat::SolanaCliJson,
Self::SolanaKeypairBase58 => ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58,
};
}
/// Returns the native picker extension used as a convenience filter.
#[must_use]
pub(crate) const fn default_extension(self) -> &'static str {
return match self {
Self::SolanaCliJson => "json",
Self::SolanaKeypairBase58 => "txt",
};
}
/// Returns the stable transfer format code used by safe diagnostics.
#[must_use]
pub(crate) const fn code(self) -> &'static str {
return match self {
Self::SolanaCliJson => "solana_cli_json",
Self::SolanaKeypairBase58 => "solana_keypair_base58",
};
}
const fn maximum_source_bytes(self) -> usize {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES,
Self::SolanaKeypairBase58 => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES,
};
}
}
/// Request that chooses only the transfer format; the source path comes from the native Rust picker.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletImportSourceRequestDto.ts")]
pub(crate) struct WalletImportSourceRequestDto {
/// Transfer encoding expected for the selected external file.
pub(crate) format: WalletTransferFormatDto,
pub(crate) format: crate::WalletTransferFormatDto,
}
/// Safe projection returned after validating one native-picker import source.
@@ -68,7 +21,7 @@ pub(crate) struct WalletImportSourceRequestDto {
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletTransferInspectionDto.ts")]
pub(crate) struct WalletTransferInspectionDto {
/// Transfer format used to validate the secret source.
pub(crate) format: WalletTransferFormatDto,
pub(crate) format: crate::WalletTransferFormatDto,
/// Public identity derived from the imported keypair.
pub(crate) pubkey: String,
/// Basename only; no parent path is ever projected.
@@ -95,7 +48,7 @@ pub(crate) struct WalletImportRequestDto {
/// Rust-only staged transfer source. Secret bytes are bounded and zeroized on drop.
pub(crate) struct PendingWalletImport {
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
format: WalletTransferFormatDto,
format: crate::WalletTransferFormatDto,
inspection: WalletTransferInspectionDto,
}
@@ -120,7 +73,10 @@ impl PendingWalletImport {
}
/// Reads, bounds and validates one native-picker transfer source while retaining no external path.
pub(crate) async fn stage_wallet_import_source(source: std::path::PathBuf, format: WalletTransferFormatDto) -> ksp_core_lib::Result<PendingWalletImport> {
pub(crate) async fn stage_wallet_import_source(
source: std::path::PathBuf,
format: crate::WalletTransferFormatDto,
) -> ksp_core_lib::Result<PendingWalletImport> {
let metadata = tokio::fs::symlink_metadata(source.as_path()).await;
let metadata = match metadata {
std::result::Result::Ok(value) => value,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/wallet_session.rs
// version: 7
// version: 8
//! Durable root-scoped Wallet session lifecycle for Wallet Desk.
@@ -170,7 +170,7 @@ pub(crate) enum WalletSession {
/// Whether the authenticated Wallet currently exposes a VIEW slot.
view_enabled: bool,
},
/// One OWNER-authorized privileged mutation temporarily owns the handle outside the session mutex.
/// One OWNER-authorized privileged operation temporarily owns the handle outside the session mutex.
OwnerOperation {
/// Native Wallet filename / root-scoped identifier.
wallet_id: String,

View File

@@ -0,0 +1,59 @@
// file: crates/ksp-app-wallet-desk/src/wallet_transfer.rs
// version: 1
//! Shared Wallet Desk transfer format contract used by import and OWNER export workflows.
use ts_rs::TS; // rust-rules: trait-import
/// Explicit Solana keypair transfer format selected by Wallet Desk.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_transfer/WalletTransferFormatDto.ts")]
pub(crate) enum WalletTransferFormatDto {
/// Standard Solana CLI keypair JSON array containing 64 byte values.
SolanaCliJson,
/// Canonical Base58 encoding of the complete 64-byte Solana keypair.
SolanaKeypairBase58,
}
impl WalletTransferFormatDto {
/// Maps the desktop DTO to the stable Wallet transfer format.
#[must_use]
pub(crate) const fn wallet_format(self) -> ksp_wallet_lib::WalletTransferFormat {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::WalletTransferFormat::SolanaCliJson,
Self::SolanaKeypairBase58 => ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58,
};
}
/// Returns the native picker/save extension used as a convenience filter.
#[must_use]
pub(crate) const fn default_extension(self) -> &'static str {
return match self {
Self::SolanaCliJson => "json",
Self::SolanaKeypairBase58 => "txt",
};
}
/// Returns the stable transfer format code used by safe diagnostics.
#[must_use]
pub(crate) const fn code(self) -> &'static str {
return match self {
Self::SolanaCliJson => "solana_cli_json",
Self::SolanaKeypairBase58 => "solana_keypair_base58",
};
}
/// Returns the maximum accepted import source size for this transfer encoding.
#[must_use]
pub(crate) const fn maximum_source_bytes(self) -> usize {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES,
Self::SolanaKeypairBase58 => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES,
};
}
}
#[cfg(test)]
#[path = "../unit_tests/wallet_transfer.rs"]
mod tests;

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Wallet Desk",
"version": "0.2.6-pre.11",
"version": "0.2.6-pre.12",
"identifier": "com.sasedev.ksp-app-wallet-desk",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
// version: 12
// version: 13
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -302,5 +302,32 @@ fn pre_011_strong_view_administration_is_owner_only_and_refreshes_safe_status()
assert!(main.contains("runStrongViewDisable"));
assert!(main.contains("runStrongViewRecreate"));
assert!(main.contains("applyViewSecurityStatus"));
assert!(main.contains("pre.011-strong-view-administration"));
assert!(main.contains("bindStrongViewSecurityActions"));
}
#[test]
fn pre_012_owner_export_uses_rust_native_save_picker_and_wallet_file_adapter() {
let root = app_root();
let state = read_text(root.join("src/app_state.rs").as_path());
let export = read_text(root.join("src/wallet_export.rs").as_path());
let transfer = read_text(root.join("src/wallet_transfer.rs").as_path());
let tauri = read_text(root.join("src/tauri.rs").as_path());
let html = read_text(root.join("frontend/main.html").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(export.contains("WalletExportRequestDto"));
assert!(export.contains("WalletExportResultDto"));
assert!(transfer.contains("WalletTransferFormatDto"));
assert!(transfer.contains("solana_cli_json"));
assert!(transfer.contains("solana_keypair_base58"));
assert!(state.contains("wallet_export_picker_filename"));
assert!(state.contains("begin_owner_operation(\"keypair_export\")"));
assert!(state.contains("owner.export_transfer_file"));
assert!(tauri.contains("blocking_save_file"));
assert!(tauri.contains("export_wallet_transfer"));
assert!(html.contains("id=\"walletExportFormat\""));
assert!(html.contains("id=\"exportWalletKeypair\""));
assert!(html.contains("id=\"exportWalletKeypairModal\""));
assert!(main.contains("bindWalletExportActions"));
assert!(main.contains("export_wallet_transfer"));
assert!(main.contains("pre.012-owner-transfer-export"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
// version: 12
// version: 13
//! Static desktop security contracts for the Wallet Desk pre.002 shell.
@@ -181,7 +181,7 @@ fn owner_metadata_values_are_request_only_and_state_conflict_forces_reauthorizat
assert!(!main.contains("frontendTrace(\"main\", request.text"));
assert!(!main.contains("frontendDebug(\"main\", request.alias"));
assert!(!main.contains("window.confirm"));
assert!(main.contains("new Modal(modalElement)"));
assert!(main.contains("deleteNoteModal = new Modal(modalElement)"));
}
#[test]
@@ -262,3 +262,63 @@ fn strong_view_administration_is_owner_only_uses_bootstrap_modals_and_returns_no
assert!(!main.contains("frontendDebug(\"main\", request.password"));
assert!(!main.contains("frontendTrace(\"main\", request.password"));
}
#[test]
fn owner_export_never_projects_key_material_or_full_destination_path_to_frontend() {
let root = app_root();
let export = read_text(root.join("src/wallet_export.rs").as_path());
let state = read_text(root.join("src/app_state.rs").as_path());
let tauri = read_text(root.join("src/tauri.rs").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
let request = export.split("pub(crate) struct WalletExportRequestDto").nth(1);
assert!(request.is_some());
let request_fields = request
.unwrap_or_default()
.split("}\n")
.next()
.unwrap_or_default()
.lines()
.filter(|line| return line.trim_start().starts_with("pub(crate) "))
.collect::<std::vec::Vec<_>>()
.join("\n");
assert!(request_fields.contains("format"));
assert!(!export.contains("#[derive(Clone, serde::Deserialize"));
assert!(!export.contains("#[derive(Debug, serde::Deserialize"));
assert!(!request_fields.contains("path"));
assert!(!request_fields.contains("password"));
assert!(!request_fields.contains("secret"));
let result = export.split("pub(crate) struct WalletExportResultDto").nth(1);
assert!(result.is_some());
let result_fields = result
.unwrap_or_default()
.split("}\n")
.next()
.unwrap_or_default()
.lines()
.filter(|line| return line.trim_start().starts_with("pub(crate) "))
.collect::<std::vec::Vec<_>>()
.join("\n");
assert!(result_fields.contains("destination_name"));
assert!(result_fields.contains("format"));
assert!(result_fields.contains("wallet_id"));
assert!(!result_fields.contains("path"));
assert!(!result_fields.contains("pubkey"));
assert!(!result_fields.contains("password"));
assert!(!result_fields.contains("secret"));
assert!(!result_fields.contains("content"));
assert!(!result_fields.contains("bytes"));
assert!(state.contains("owner.export_transfer_file"));
assert!(!state.contains("owner.export_transfer(format"));
let command_start = tauri.find("async fn export_wallet_transfer");
assert!(command_start.is_some());
let command = &tauri[command_start.unwrap_or_default()..];
let command = command.split("#[tauri::command]").next().unwrap_or_default();
assert!(command.contains("WalletExportRequestDto"));
assert!(command.contains("WalletExportResultDto"));
assert!(!main.contains("navigator.clipboard"));
assert!(!main.contains("destinationPath"));
assert!(!main.contains("destination_path"));
assert!(!main.contains("keypairBytes"));
assert!(!main.contains("secretKey"));
assert!(main.contains("exportWalletModal = new Modal(modalElement)"));
}

View File

@@ -0,0 +1,41 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_export.rs
// version: 1
#[test]
fn export_request_is_deserializable_without_secret_or_path_fields() {
fn assert_deserializable<T>(_marker: std::marker::PhantomData<T>)
where
T: serde::de::DeserializeOwned,
{
return;
}
assert_deserializable(std::marker::PhantomData::<crate::WalletExportRequestDto>);
let source = include_str!("../src/wallet_export.rs");
let request = source.split("pub(crate) struct WalletExportRequestDto").nth(1);
assert!(request.is_some());
let fields = request
.unwrap_or_default()
.split("}\n")
.next()
.unwrap_or_default()
.lines()
.filter(|line| return line.trim_start().starts_with("pub(crate) "))
.collect::<std::vec::Vec<_>>()
.join("\n");
assert!(fields.contains("format"));
assert!(!fields.contains("path"));
assert!(!fields.contains("password"));
assert!(!fields.contains("secret"));
}
#[test]
fn export_result_keeps_only_safe_basename_format_and_wallet_id() {
let filename = crate::wallet_export_default_filename("alpha.kspwallet", crate::WalletTransferFormatDto::SolanaCliJson);
assert_eq!(filename, "alpha-solana-keypair.json");
let base58_filename = crate::wallet_export_default_filename("alpha.kspwallet", crate::WalletTransferFormatDto::SolanaKeypairBase58);
assert_eq!(base58_filename, "alpha-solana-keypair.txt");
let path = std::path::Path::new("/tmp/private/alpha-solana-keypair.json");
let name = crate::wallet_export_destination_name(path);
assert!(name.is_ok());
assert_eq!(name.unwrap_or_default(), "alpha-solana-keypair.json");
}

View File

@@ -1,13 +1,5 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_import.rs
// version: 1
#[test]
fn transfer_format_dto_maps_exact_supported_wallet_formats_and_bounds() {
assert_eq!(crate::WalletTransferFormatDto::SolanaCliJson.wallet_format(), ksp_wallet_lib::WalletTransferFormat::SolanaCliJson);
assert_eq!(crate::WalletTransferFormatDto::SolanaKeypairBase58.wallet_format(), ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58);
assert_eq!(crate::WalletTransferFormatDto::SolanaCliJson.maximum_source_bytes(), ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES);
assert_eq!(crate::WalletTransferFormatDto::SolanaKeypairBase58.maximum_source_bytes(), ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES);
}
// version: 2
#[test]
fn import_request_does_not_require_debug_or_clone_password_contracts() {

View File

@@ -0,0 +1,10 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_transfer.rs
// version: 1
#[test]
fn transfer_format_dto_maps_exact_supported_wallet_formats_and_bounds() {
assert_eq!(crate::WalletTransferFormatDto::SolanaCliJson.wallet_format(), ksp_wallet_lib::WalletTransferFormat::SolanaCliJson);
assert_eq!(crate::WalletTransferFormatDto::SolanaKeypairBase58.wallet_format(), ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58);
assert_eq!(crate::WalletTransferFormatDto::SolanaCliJson.maximum_source_bytes(), ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES);
assert_eq!(crate::WalletTransferFormatDto::SolanaKeypairBase58.maximum_source_bytes(), ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES);
}

58
deltas/0.2.6/pre.012.md Normal file
View File

@@ -0,0 +1,58 @@
<!-- file: deltas/0.2.6/pre.012.md -->
<!-- version: 1 -->
# Delta `0.2.6-pre.012` — export OWNER Solana CLI JSON / Base58
## Objet
Exposer dans Wallet Desk l'export explicite OWNER de la keypair Solana vers un fichier externe, en réutilisant `WalletOwner::export_transfer_file` et le plugin Dialog déjà installé, sans jamais projeter le secret ou le chemin complet vers le frontend.
## Changements
- `workspace.package.version` passe à `0.2.6-pre.12` ;
- extraction de `WalletTransferFormatDto` dans `wallet_transfer.rs`, contrat partagé import/export sans changement des codes wire ;
- ajout de `wallet_export.rs` :
- `WalletExportRequestDto` contient uniquement le format ;
- `WalletExportResultDto` contient uniquement `wallet_id`, format et basename de destination ;
- nom de fichier proposé déterministe `*-solana-keypair.{json|txt}` ;
- ajout de `export_wallet_transfer` :
- OWNER-only ;
- vérification OWNER avant ouverture du picker ;
- `blocking_save_file` exécuté uniquement dans la commande Tauri async ;
- chemin sélectionné conservé uniquement côté Rust ;
- délégation à `WalletOwner::export_transfer_file` ;
- no-clobber hérité de `ksp-wallet-lib` ;
- session OWNER restaurée après succès ou erreur non conflictuelle ;
- UI Security :
- choix Solana CLI JSON / Base58 ;
- modal Bootstrap explicite avant export d'une keypair non chiffrée ;
- save picker natif ouvert depuis Rust ;
- statut final limité au basename ;
- aucun export texte/clipboard ;
- domaine de log dédié `wallet.export` ;
- le `shell_phase` est réaligné sur `pre.012-owner-transfer-export` ;
- badge/footer Wallet Desk réalignés sur `pre.012` ;
- canaries contract/security et unit tests ajoutés ;
- `ROADMAP.md`, `CHANGELOG.md` et `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` restent inchangés.
## Sécurité
Le frontend ne fournit jamais de chemin de destination et ne reçoit jamais la keypair, son encodage ou un chemin complet. Seul le format traverse l'IPC en entrée. Le save picker et `export_transfer_file` restent côté Rust. Le contenu exporté n'est jamais loggé, et le nom de destination n'est pas persisté dans les logs.
L'export produit volontairement un fichier secret non chiffré compatible Solana. La confirmation Bootstrap avertit explicitement l'opérateur de le traiter comme une clé privée.
## Validation opérateur
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-wallet-desk
cargo test --workspace
KSP_WALLETS_DIRECTORY=var/wallet-desk-pre012 \
cargo tauri dev -c crates/ksp-app-wallet-desk/tauri.conf.json
```
Runtime : ouvrir OWNER -> exporter JSON -> réimporter dans un nouveau `.kspwallet` et vérifier la même Pubkey ; exporter Base58 -> même round-trip ; vérifier Cancel sans fichier ; tenter une destination existante et vérifier no-clobber ; confirmer qu'une session VIEW n'active jamais l'export.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md -->
<!-- version: 26 -->
<!-- version: 27 -->
# Plan `0.2.6` — Wallet Desk
@@ -1597,20 +1597,58 @@ nouveau VIEW password accepté
Pubkey / alias / notes / OWNER inchangés
```
Validation opérateur `pre.011` : **verte** le 21 août 2026. `fmt`, audit Rust KSP, `cargo check --workspace`, Clippy sans warning, tests Wallet Desk et workspace complet passent. Le runtime Tauri confirme `view_enabled=true -> strong disable -> false`, puis `strong recreate -> true`; l'ancien credential VIEW est refusé après recreate et le nouveau est accepté.
### `pre.012` — export OWNER fichier
Objectifs :
Cette tranche expose uniquement l'adapter fichier déjà possédé par `WalletOwner`; Wallet Desk ne sérialise jamais lui-même la keypair.
`WalletTransferFormatDto` quitte `wallet_import.rs` pour un module Desk commun `wallet_transfer.rs`, puisque le même contrat est désormais consommé par import et export. Les codes IPC restent inchangés.
Contrat retenu :
```text
plugin Dialog save minimal
choix CLI JSON/Base58
confirmation OWNER
export_transfer_file
no-clobber
aucun export texte/clipboard
leak audit ciblé
OWNER session requise avant ouverture du save picker
frontend -> Rust : format uniquement
Rust -> native save picker : destination locale
Rust -> WalletOwner::export_transfer_file(destination, format)
Wallet -> création atomique no-clobber du fichier secret
Rust -> frontend : wallet_id + format + basename uniquement
```
Formats disponibles :
```text
Solana CLI JSON
Solana keypair Base58 complet
```
Le frontend ne reçoit jamais :
```text
chemin complet de destination
bytes de keypair
Base58 de keypair
JSON de keypair
password OWNER
handle WalletOwner
```
L'opération réutilise `OwnerOperation` afin de déplacer le handle OWNER hors du mutex pendant l'écriture async. Une annulation du picker ne touche pas la session. Une erreur d'I/O ou `destination_exists` restaure la session OWNER ; la primitive Wallet conserve le no-clobber et les permissions privées best-effort sur Unix.
UX :
```text
format CLI JSON / Base58
modal Bootstrap avertissant qu'il s'agit d'une keypair non chiffrée
confirmation -> save picker natif Rust
aucun export texte
aucun clipboard
aucun chemin arbitraire fourni par le frontend
status final avec basename seulement
```
Le nom de destination et le contenu secret ne sont pas journalisés. Les logs persistants ne contiennent que `wallet_id`, format et statut de l'opération.
### `pre.013` — intégration, compliance, sécurité et smoke
Objectifs :