v0.2.6-pre.010

This commit is contained in:
2026-08-21 13:11:16 +02:00
parent 42326e7f02
commit 4dcdbfaa55
15 changed files with 389 additions and 57 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 178
# version: 179
[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.9.fix.1"
version = "0.2.6-pre.10"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -326,6 +326,35 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Rotation credentials OWNER</div>
<div class="card-body">
<p class="small text-body-secondary">Les rotations nécessitent une session OWNER. Elles modifient le fichier Wallet, mais ne changent jamais automatiquement les secrets Config.</p>
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label" for="rotateOwnerPassword">Nouveau OWNER password</label>
<input id="rotateOwnerPassword" class="form-control mb-2" type="password" autocomplete="new-password" data-owner-rotation-control disabled>
<label class="form-label" for="rotateOwnerPasswordConfirm">Confirmer OWNER password</label>
<div class="input-group">
<input id="rotateOwnerPasswordConfirm" class="form-control" type="password" autocomplete="new-password" data-owner-rotation-control disabled>
<button id="rotateOwnerPasswordSubmit" class="btn btn-outline-danger" type="button" data-owner-rotation-control disabled>Rotate OWNER</button>
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="rotateViewPassword">Nouveau VIEW password</label>
<input id="rotateViewPassword" class="form-control mb-2" type="password" autocomplete="new-password" data-view-rotation-control disabled>
<label class="form-label" for="rotateViewPasswordConfirm">Confirmer VIEW password</label>
<div class="input-group">
<input id="rotateViewPasswordConfirm" class="form-control" type="password" autocomplete="new-password" data-view-rotation-control disabled>
<button id="rotateViewPasswordSubmit" class="btn btn-outline-primary" type="button" data-view-rotation-control disabled>Rotate VIEW</button>
</div>
</div>
</div>
<div id="rotationStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session OWNER requise.</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>
@@ -360,7 +389,7 @@
</div>
</div>
<footer class="app-footer bg-dark text-white-50 d-flex align-items-center px-4">
<span>KSP Wallet Desk · OWNER metadata pre.009</span>
<span>KSP Wallet Desk · credential rotation pre.010</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: 9
// version: 10
import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5";
@@ -21,6 +21,7 @@ import type { WalletAliasUpdateRequestDto } from "./bindings/ksp_app_wallet_desk
import type { WalletNoteAddRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteAddRequestDto.ts";
import type { WalletNoteDeleteRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteDeleteRequestDto.ts";
import type { WalletNoteUpdateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_metadata/WalletNoteUpdateRequestDto.ts";
import type { WalletPasswordRotationRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_security/WalletPasswordRotationRequestDto.ts";
import type { WalletAuthorizedDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletAuthorizedDto.ts";
import type { WalletCreateRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletCreateRequestDto.ts";
import type { WalletSessionDto } from "./bindings/ksp_app_wallet_desk/wallet_session/WalletSessionDto.ts";
@@ -183,7 +184,7 @@ function initializeWalletTable(): void {
zeroRecords: "Aucun wallet correspondant.",
},
});
frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.008-wallet-import" });
frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.010-credential-rotation" });
}
function renderWalletInventory(entries: WalletInventoryEntryDto[]): void {
@@ -221,6 +222,7 @@ function updateSessionActions(): void {
}
updateUnlockActions();
updateOwnerMetadataActions();
updateSecurityRotationActions();
}
function updateOwnerMetadataActions(): void {
@@ -230,6 +232,17 @@ function updateOwnerMetadataActions(): void {
});
}
function updateSecurityRotationActions(): void {
const ownerOpen = activeSessionState === "owner_open" && authorizedWalletProjection?.capability === "owner";
document.querySelectorAll<HTMLInputElement | HTMLButtonElement>("[data-owner-rotation-control]").forEach(control => {
control.disabled = !ownerOpen;
});
const viewControls = document.querySelectorAll<HTMLInputElement | HTMLButtonElement>("[data-view-rotation-control]");
viewControls.forEach(control => {
control.disabled = !ownerOpen || !activeViewEnabled;
});
}
function updateUnlockActions(): void {
const locked = activeSessionState === "locked";
const viewManual = document.querySelector<HTMLButtonElement>("#unlockViewManual");
@@ -319,6 +332,15 @@ function clearUnlockSensitiveInputs(): void {
}
}
function clearRotationSensitiveInputs(): void {
for (const selector of ["#rotateOwnerPassword", "#rotateOwnerPasswordConfirm", "#rotateViewPassword", "#rotateViewPasswordConfirm"]) {
const input = document.querySelector<HTMLInputElement>(selector);
if (input) {
input.value = "";
}
}
}
function clearBalanceProjection(): void {
setText("#currentWalletBalanceSol", "—");
setText("#detailsBalanceLamports", "—");
@@ -340,7 +362,9 @@ function clearAuthorizedProjection(): void {
setText("#detailsWalletNotes", "—");
clearBalanceProjection();
clearCreateFormSensitiveInputs();
clearRotationSensitiveInputs();
clearUnlockSensitiveInputs();
setText("#rotationStatus", "Session OWNER requise.");
}
function clearSelectedWallet(): void {
@@ -415,6 +439,12 @@ function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
clearBalanceProjection();
setText("#balanceStatus", "Session autorisée. Refresh balance appelle getBalance avec la Pubkey détenue par Rust.");
setText("#unlockStatus", `${capabilityLabel} ouvert. Lock pour purger le handle autorisé.`);
setText(
"#rotationStatus",
wallet.capability === "owner"
? "OWNER ouvert. Une rotation modifie le fichier Wallet uniquement ; les secrets Config restent inchangés."
: "Session OWNER requise pour effectuer une rotation de credential.",
);
updateSessionActions();
frontendDebug("main", "Authorized Wallet session rendered", {
capability: wallet.capability,
@@ -510,7 +540,7 @@ function isWalletStateConflict(caughtError: unknown): boolean {
return error.domain === "wallet" && error.code === "state_conflict";
}
async function recoverOwnerMetadataStateConflict(walletId: string): Promise<void> {
async function recoverOwnerStateConflict(walletId: string): Promise<void> {
clearAuthorizedProjection();
clearBalanceProjection();
const request: WalletSelectionRequestDto = { walletId };
@@ -518,6 +548,7 @@ async function recoverOwnerMetadataStateConflict(walletId: string): Promise<void
const wallet = await invokeKsp<LockedWalletDto>("main", "select_wallet", { request });
renderLockedWallet(wallet);
setText("#ownerMetadataStatus", "Le fichier wallet a changé hors de cette session. Le handle OWNER stale a été purgé ; unlock OWNER requis avant toute nouvelle mutation.");
setText("#rotationStatus", "Conflit détat : rotation interrompue, wallet reverrouillé et réautorisation OWNER obligatoire.");
setText("#unlockStatus", "Conflit détat détecté : wallet réinspecté et reverrouillé. Réautoriser OWNER explicitement.");
await loadWalletInventory("list_wallets", false);
activateView("security", "user");
@@ -525,6 +556,7 @@ async function recoverOwnerMetadataStateConflict(walletId: string): Promise<void
} catch {
clearSelectedWallet();
setText("#ownerMetadataStatus", "Conflit détat détecté et réinspection impossible. Resélectionner le wallet depuis linventaire.");
setText("#rotationStatus", "Conflit détat détecté ; la réinspection du wallet a échoué.");
frontendWarn("main", "Wallet state conflict recovery failed", { walletId });
}
}
@@ -557,7 +589,7 @@ async function runOwnerMetadataMutation(
return true;
} catch (caughtError) {
if (isWalletStateConflict(caughtError)) {
await recoverOwnerMetadataStateConflict(walletId);
await recoverOwnerStateConflict(walletId);
return false;
}
if (activeWalletId === walletId) {
@@ -930,6 +962,79 @@ async function unlockConfigured(capability: UnlockCapability): Promise<void> {
}
}
async function rotateWalletPassword(capability: UnlockCapability): Promise<void> {
if (activeSessionState !== "owner_open" || !activeWalletId || authorizedWalletProjection?.capability !== "owner") {
clearRotationSensitiveInputs();
setText("#rotationStatus", "Session OWNER requise.");
return;
}
if (capability === "view" && !activeViewEnabled) {
clearRotationSensitiveInputs();
setText("#rotationStatus", "VIEW est désactivé ; aucune rotation VIEW nest possible.");
return;
}
const passwordSelector = capability === "owner" ? "#rotateOwnerPassword" : "#rotateViewPassword";
const confirmationSelector = capability === "owner" ? "#rotateOwnerPasswordConfirm" : "#rotateViewPasswordConfirm";
const password = document.querySelector<HTMLInputElement>(passwordSelector);
const confirmation = document.querySelector<HTMLInputElement>(confirmationSelector);
if (!password || !confirmation || password.value.length === 0) {
clearRotationSensitiveInputs();
setText("#rotationStatus", "Nouveau password et confirmation requis.");
return;
}
if (password.value !== confirmation.value) {
clearRotationSensitiveInputs();
setText("#rotationStatus", "La confirmation ne correspond pas au nouveau password.");
return;
}
const walletId = activeWalletId;
const request: WalletPasswordRotationRequestDto = { password: password.value };
const command = capability === "owner" ? "rotate_owner_password" : "rotate_view_password";
activeSessionState = "privileged_operation";
setText("#currentWalletState", `Rotation ${capability.toUpperCase()}`);
setText("#rotationStatus", "Rotation Argon2 en cours…");
updateSessionActions();
frontendDebug("main", "Wallet credential rotation requested", { capability, walletId });
try {
const wallet = await invokeKsp<WalletAuthorizedDto>("main", command, { request });
clearRotationSensitiveInputs();
if (activeWalletId !== walletId) {
frontendWarn("main", "Stale Wallet credential rotation response ignored", { capability, walletId });
return;
}
renderAuthorizedWallet(wallet);
setText(
"#rotationStatus",
`${capability.toUpperCase()} password rotated. Les secrets Config (${configuredSecretCandidateCount} candidat(s)) ne sont pas modifiés automatiquement.`,
);
await loadWalletInventory("list_wallets", false);
frontendInfo("main", "Wallet credential rotation completed", { capability, walletId });
} catch (caughtError) {
clearRotationSensitiveInputs();
if (isWalletStateConflict(caughtError)) {
await recoverOwnerStateConflict(walletId);
return;
}
if (activeWalletId === walletId) {
activeSessionState = "owner_open";
setText("#currentWalletState", "OWNER open");
setText("#rotationStatus", "Rotation refusée ; la session OWNER reste ouverte.");
updateSessionActions();
}
frontendWarn("main", "Wallet credential rotation failed", { capability, walletId });
}
}
function bindRotationActions(): void {
document.querySelector<HTMLButtonElement>("#rotateOwnerPasswordSubmit")?.addEventListener("click", () => {
void rotateWalletPassword("owner");
});
document.querySelector<HTMLButtonElement>("#rotateViewPasswordSubmit")?.addEventListener("click", () => {
void rotateWalletPassword("view");
});
frontendTrace("main", "Wallet credential rotation handlers installed");
}
function bindUnlockActions(): void {
document.querySelector<HTMLButtonElement>("#unlockViewManual")?.addEventListener("click", () => {
void unlockManual("view");
@@ -1033,7 +1138,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 OWNER et getBalance prêts.");
setText("#shellStatus", "Config/Transport résolus ; import, VIEW/OWNER, metadata, rotations et getBalance prêts.");
frontendTrace("main", "Wallet Desk runtime status rendered", {
compositeProfile: status.activeCompositeProfile,
fallbackLoggingActive: status.fallbackLoggingActive,
@@ -1085,6 +1190,7 @@ async function initializeMain(): Promise<void> {
bindUnlockActions();
bindBalanceActions();
bindOwnerMetadataActions();
bindRotationActions();
bindShellActions();
clearSelectedWallet();
activateView("dashboard", "startup");

View File

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 12
// version: 13
//! 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.009-owner-metadata".to_owned(),
shell_phase: "pre.010-credential-rotation".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
transport_available_endpoint_count,
transport_clusters: self.transport_runtime.clusters(),
@@ -492,7 +492,7 @@ impl AppState {
*session = crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled };
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet OWNER metadata operation is already in progress",
"Wallet OWNER privileged operation is already in progress",
));
},
crate::WalletSession::View { wallet_id, path, wallet, .. } => {
@@ -663,19 +663,19 @@ impl AppState {
/// Updates or clears the protected alias through the current OWNER handle.
pub(crate) async fn update_wallet_alias(&self, request: crate::WalletAliasUpdateRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("alias_update");
let context = self.begin_owner_operation("alias_update");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let alias = normalize_optional_text(request.alias);
let result = context.owner.update_alias(context.path.as_path(), alias).await;
return self.finish_owner_metadata_operation(context, result).await;
return self.finish_owner_operation(context, result).await;
}
/// Appends one protected note through the current OWNER handle.
pub(crate) async fn add_wallet_note(&self, request: crate::WalletNoteAddRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_add");
let context = self.begin_owner_operation("note_add");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -685,32 +685,63 @@ impl AppState {
std::result::Result::Ok(_) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
return self.finish_owner_metadata_operation(context, result).await;
return self.finish_owner_operation(context, result).await;
}
/// Updates one protected note through the current OWNER handle.
pub(crate) async fn update_wallet_note(&self, request: crate::WalletNoteUpdateRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_update");
let context = self.begin_owner_operation("note_update");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = context.owner.update_note(context.path.as_path(), request.note_id.as_str(), request.text).await;
return self.finish_owner_metadata_operation(context, result).await;
return self.finish_owner_operation(context, result).await;
}
/// Deletes one protected note through the current OWNER handle.
pub(crate) async fn delete_wallet_note(&self, request: crate::WalletNoteDeleteRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_metadata_operation("note_delete");
let context = self.begin_owner_operation("note_delete");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = context.owner.delete_note(context.path.as_path(), request.note_id.as_str()).await;
return self.finish_owner_metadata_operation(context, result).await;
return self.finish_owner_operation(context, result).await;
}
fn begin_owner_metadata_operation(&self, operation: &'static str) -> ksp_core_lib::Result<OwnerMetadataContext> {
/// Rotates the OWNER password while preserving the current authorized identity and metadata projection.
pub(crate) async fn rotate_owner_password(&self, request: crate::WalletPasswordRotationRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_operation("owner_password_rotate");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let password = ksp_wallet_lib::OwnerPassword::new(request.password);
let result = context.owner.rotate_owner_password(context.path.as_path(), password).await;
return self.finish_owner_operation(context, result).await;
}
/// Rotates the enabled VIEW password from the current OWNER session without requiring the previous VIEW password.
pub(crate) async fn rotate_view_password(&self, request: crate::WalletPasswordRotationRequestDto) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let context = self.begin_owner_operation("view_password_rotate");
let mut context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if !context.view_enabled {
self.restore_owner_after_operation_failure(context);
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_VIEW_DISABLED,
"Wallet VIEW capability is disabled and cannot rotate a VIEW password",
));
}
let password = ksp_wallet_lib::ViewPassword::new(request.password);
let result = context.owner.rotate_view_password(context.path.as_path(), password).await;
return self.finish_owner_operation(context, result).await;
}
fn begin_owner_operation(&self, operation: &'static str) -> ksp_core_lib::Result<OwnerOperationContext> {
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
@@ -723,35 +754,35 @@ impl AppState {
*session = other;
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet metadata administration requires an authorized OWNER session",
"Wallet OWNER operation requires an authorized OWNER session",
));
},
};
let pubkey = owner.pubkey().to_owned();
let context = OwnerMetadataContext { operation, owner, path: path.clone(), pubkey, view_enabled, wallet_id: wallet_id.clone() };
let context = OwnerOperationContext { operation, owner, path: path.clone(), pubkey, view_enabled, wallet_id: wallet_id.clone() };
*session = crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = context.wallet_id.as_str(), operation, "Wallet OWNER metadata operation started");
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = context.wallet_id.as_str(), operation, "Wallet OWNER privileged operation started");
return std::result::Result::Ok(context);
}
async fn finish_owner_metadata_operation(
async fn finish_owner_operation(
&self,
context: OwnerMetadataContext,
context: OwnerOperationContext,
operation_result: ksp_core_lib::Result<()>,
) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
if let std::result::Result::Err(error) = operation_result {
if error.code() == ksp_wallet_lib::ERROR_CODE_STATE_CONFLICT {
self.recover_owner_metadata_state_conflict(context).await;
self.recover_owner_state_conflict(context).await;
return std::result::Result::Err(error);
}
self.restore_owner_after_metadata_failure(context);
self.restore_owner_after_operation_failure(context);
return std::result::Result::Err(error);
}
return self.install_owner_after_metadata_success(context);
return self.install_owner_after_operation_success(context);
}
fn install_owner_after_metadata_success(&self, context: OwnerMetadataContext) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
fn install_owner_after_operation_success(&self, context: OwnerOperationContext) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let OwnerOperationContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
let configured_secret_candidate_count = self.secret_candidate_count_or_zero(wallet_id.as_str());
let dto = crate::owner_projection(wallet_id.as_str(), view_enabled, configured_secret_candidate_count, &owner);
let session = self.wallet_session.lock();
@@ -768,7 +799,7 @@ impl AppState {
view_enabled: reserved_view_enabled,
} if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey && reserved_view_enabled == view_enabled => {
*session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, view_enabled, wallet: owner };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata operation completed");
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER privileged operation completed");
return std::result::Result::Ok(dto);
},
other => {
@@ -776,14 +807,14 @@ impl AppState {
drop(owner);
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet metadata completion no longer owns the selected OWNER session",
"Wallet OWNER operation completion no longer owns the selected session",
));
},
}
}
fn restore_owner_after_metadata_failure(&self, context: OwnerMetadataContext) {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
fn restore_owner_after_operation_failure(&self, context: OwnerOperationContext) {
let OwnerOperationContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
@@ -798,7 +829,7 @@ impl AppState {
view_enabled: reserved_view_enabled,
} if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey && reserved_view_enabled == view_enabled => {
*session = crate::WalletSession::Owner { wallet_id: wallet_id.clone(), path, view_enabled, wallet: owner };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata operation failed without invalidating the authenticated handle");
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER privileged operation failed without invalidating the authenticated handle");
},
other => {
*session = other;
@@ -807,8 +838,8 @@ impl AppState {
}
}
async fn recover_owner_metadata_state_conflict(&self, context: OwnerMetadataContext) {
let OwnerMetadataContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
async fn recover_owner_state_conflict(&self, context: OwnerOperationContext) {
let OwnerOperationContext { operation, owner, path, pubkey, view_enabled, wallet_id } = context;
drop(owner);
let inspected = ksp_wallet_lib::inspect_locked_wallet_file_v1(path.as_path()).await;
let session = self.wallet_session.lock();
@@ -827,7 +858,7 @@ impl AppState {
if let std::result::Result::Ok(locked_info) = inspected {
*session = crate::WalletSession::Locked { wallet_id: wallet_id.clone(), path, locked_info };
}
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER metadata state conflict purged the stale handle and forced reauthorization");
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet OWNER state conflict purged the stale handle and forced reauthorization");
},
other => {
*session = other;
@@ -964,7 +995,7 @@ fn configured_secret_missing_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_WALLET_SECRET_CANDIDATES_MISSING, "No effective Config-owned Wallet password candidate is available");
}
struct OwnerMetadataContext {
struct OwnerOperationContext {
operation: &'static str,
owner: std::boxed::Box<ksp_wallet_lib::WalletOwner>,
path: std::path::PathBuf,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 9
// version: 10
//! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -25,6 +25,7 @@ mod wallet_import;
mod wallet_inventory;
mod wallet_metadata;
mod wallet_secrets;
mod wallet_security;
mod wallet_session;
/// Runs the KSP wallet desktop application.
@@ -199,6 +200,8 @@ pub(crate) use self::wallet_metadata::WalletNoteUpdateRequestDto;
pub(crate) use self::wallet_secrets::discover_wallet_secret_candidates;
/// Returns the configured Wallet password candidate count without exposing their names or values.
pub(crate) use self::wallet_secrets::wallet_secret_candidate_count;
/// New credential request moved frontend -> Rust only for one explicit OWNER-authorized rotation.
pub(crate) use self::wallet_security::WalletPasswordRotationRequestDto;
/// Authorized OWNER projection exposed only after successful creation.
pub(crate) use self::wallet_session::WalletAuthorizedDto;
/// Create request whose password strings move frontend -> Rust only.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/tauri.rs
// version: 7
// version: 8
//! Tauri runtime assembly for the KSP wallet desktop application.
@@ -53,6 +53,8 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
lock_wallet,
refresh_wallet_balance,
refresh_wallets,
rotate_owner_password,
rotate_view_password,
select_wallet,
splash_frontend_ready,
unlock_wallet_owner_configured,
@@ -248,6 +250,30 @@ async fn refresh_wallets(
};
}
#[tauri::command]
async fn rotate_owner_password(
request: crate::WalletPasswordRotationRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.rotate_owner_password(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
async fn rotate_view_password(
request: crate::WalletPasswordRotationRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::WalletAuthorizedDto, crate::CommandErrorDto> {
let result = state.rotate_view_password(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
async fn select_wallet(
request: crate::WalletSelectionRequestDto,

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-app-wallet-desk/src/wallet_security.rs
// version: 1
//! OWNER-only credential rotation contracts for Wallet Desk.
use ts_rs::TS; // rust-rules: trait-import
/// New credential moved frontend -> Rust for one explicit Wallet password rotation.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_security/WalletPasswordRotationRequestDto.ts")]
pub(crate) struct WalletPasswordRotationRequestDto {
/// New password transported only for the explicit rotation call.
pub(crate) password: String,
}
#[cfg(test)]
#[path = "../unit_tests/wallet_security.rs"]
mod tests;

View File

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Wallet Desk",
"version": "0.2.6-pre.9.fix.1",
"version": "0.2.6-pre.10",
"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: 9
// version: 10
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -251,5 +251,27 @@ fn pre_009_owner_metadata_administration_uses_wallet_owner_and_refreshes_project
assert!(html.contains("id=\"ownerNotesList\""));
assert!(html.contains("id=\"deleteWalletNoteModal\""));
assert!(main.contains("runOwnerMetadataMutation"));
assert!(main.contains("recoverOwnerMetadataStateConflict"));
assert!(main.contains("recoverOwnerStateConflict"));
}
#[test]
fn pre_010_owner_authorized_credential_rotations_preserve_session_projection() {
let root = app_root();
let security = read_text(root.join("src/wallet_security.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 html = read_text(root.join("frontend/main.html").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(security.contains("WalletPasswordRotationRequestDto"));
assert!(state.contains("owner.rotate_owner_password"));
assert!(state.contains("owner.rotate_view_password"));
assert!(state.contains("begin_owner_operation"));
assert!(state.contains("finish_owner_operation"));
assert!(tauri.contains("rotate_owner_password"));
assert!(tauri.contains("rotate_view_password"));
assert!(html.contains("id=\"rotateOwnerPasswordSubmit\""));
assert!(html.contains("id=\"rotateViewPasswordSubmit\""));
assert!(main.contains("rotateWalletPassword"));
assert!(main.contains("bindRotationActions"));
assert!(main.contains("Les secrets Config"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
// version: 9
// version: 10
//! Static desktop security contracts for the Wallet Desk pre.002 shell.
@@ -171,7 +171,7 @@ fn owner_metadata_values_are_request_only_and_state_conflict_forces_reauthorizat
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(!metadata.contains("derive(Clone"));
assert!(!metadata.contains("derive(Debug"));
assert!(state.contains("recover_owner_metadata_state_conflict"));
assert!(state.contains("recover_owner_state_conflict"));
assert!(state.contains("inspect_locked_wallet_file_v1"));
assert!(state.contains("ERROR_CODE_STATE_CONFLICT"));
assert!(main.contains("isWalletStateConflict"));
@@ -183,3 +183,33 @@ fn owner_metadata_values_are_request_only_and_state_conflict_forces_reauthorizat
assert!(!main.contains("window.confirm"));
assert!(main.contains("new Modal(modalElement)"));
}
#[test]
fn rotation_passwords_are_request_only_confirmed_frontend_side_and_never_logged() {
let root = app_root();
let security = read_text(root.join("src/wallet_security.rs").as_path());
let state = read_text(root.join("src/app_state.rs").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
assert!(!security.contains("derive(Clone"));
assert!(!security.contains("derive(Debug"));
let request = security.split("pub(crate) struct WalletPasswordRotationRequestDto").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_eq!(fields.matches("password").count(), 1);
assert!(!fields.contains("confirm"));
assert!(state.contains("OwnerPassword::new(request.password)"));
assert!(state.contains("ViewPassword::new(request.password)"));
assert!(main.contains("password.value !== confirmation.value"));
assert!(main.contains("clearRotationSensitiveInputs"));
assert!(!main.contains("frontendDebug(\"main\", request.password"));
assert!(!main.contains("frontendTrace(\"main\", request.password"));
assert!(!main.contains("KSP_SECRET_WALLET_PASS_"));
}

View File

@@ -0,0 +1,13 @@
// file: crates/ksp-app-wallet-desk/unit_tests/wallet_security.rs
// version: 1
#[test]
fn password_rotation_request_is_deserializable_without_copy_contracts() {
fn assert_deserializable<T>(_marker: std::marker::PhantomData<T>)
where
T: serde::de::DeserializeOwned,
{
return;
}
assert_deserializable(std::marker::PhantomData::<crate::WalletPasswordRotationRequestDto>);
}

42
deltas/0.2.6/pre.010.md Normal file
View File

@@ -0,0 +1,42 @@
<!-- file: deltas/0.2.6/pre.010.md -->
<!-- version: 1 -->
# Delta `0.2.6-pre.010` — rotations credentials OWNER / VIEW
## Objet
Cette tranche ajoute les rotations de credentials déjà supportées par `ksp-wallet-lib` à Wallet Desk, sans modifier le format `.kspwallet` ni les secrets Config.
## Changements
- ajoute `WalletPasswordRotationRequestDto`, request-only et sans `Debug`/`Clone` ;
- ajoute `rotate_owner_password` et `rotate_view_password` au bridge Tauri ;
- délègue à `WalletOwner::rotate_owner_password` et `WalletOwner::rotate_view_password` ;
- généralise le lifecycle `OwnerOperation` de `pre.009` pour les mutations OWNER privilégiées ;
- libère le mutex de session pendant Argon2/persistence ;
- conserve la session OWNER et la projection autorisée après succès ;
- restaure la session OWNER après une erreur non conflictuelle ;
- traite `wallet.state_conflict` par destruction du handle stale, réinspection Locked et réautorisation obligatoire ;
- refuse une rotation VIEW quand VIEW est désactivé ;
- ajoute double saisie de confirmation côté frontend uniquement ;
- vide les champs de rotation après succès, échec, Lock, Deselect ou changement de wallet ;
- indique explicitement que les secrets Config ne sont jamais mis à jour automatiquement ;
- ajoute les canaris contract/security dédiés.
## Documentation
- `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md` détaille le contrat `pre.010`.
- `ROADMAP.md`, `CHANGELOG.md` et `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` restent inchangés dans cette tranche.
## Validation opérateur attendue
```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
```
Puis `cargo tauri dev` pour vérifier les rotations réelles OWNER/VIEW, la conservation de la Pubkey et le comportement des anciens/nouveaux passwords.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md -->
<!-- version: 23 -->
<!-- version: 24 -->
# Plan `0.2.6` — Wallet Desk
@@ -1510,17 +1510,28 @@ Le correctif `pre.009-fix.001` est strictement hygiénique : suppression du cham
### `pre.010` — rotations credentials
Objectifs :
Objectifs réalisés :
```text
rotate OWNER password
rotate VIEW password
password confirmation UX
configured-secret interaction après rotation
session/projection coherence
redaction tests
rotate OWNER password depuis une session OWNER
rotate VIEW password depuis une session OWNER lorsque VIEW est activé
double saisie/confirmation uniquement dans le frontend
un seul nouveau password traverse IPC vers Rust
conversion immédiate en OwnerPassword / ViewPassword
réutilisation du lifecycle OwnerOperation hors mutex pendant Argon2 + persistence
wallet.state_conflict => purge du handle stale + retour Locked + re-unlock OWNER
session OWNER conservée après succès ou échec non conflictuel
Pubkey, alias, notes et identité Solana inchangés
VIEW rotation sans ancien password VIEW conformément au contrat WalletOwner
secrets Config volontairement inchangés après rotation
message UI explicite avant Lock/unlock configuré
redaction/canaries request-only
```
La rotation ne choisit ni ne modifie implicitement un `KSP_SECRET_WALLET_PASS_*`. Plusieurs candidats Config peuvent viser le même filename et leur politique de maintenance appartient à Config/opérateur. Wallet Desk expose donc seulement le compteur sûr déjà existant et avertit qu'un candidat contenant l'ancien password cessera de fonctionner après Lock tant qu'il n'aura pas été mis à jour.
Les deux commandes retournent la projection OWNER autorisée après succès afin de conserver une session cohérente sans réexposer de credential. Le password de confirmation reste strictement local au frontend et n'est jamais envoyé à Rust.
### `pre.011` — strong VIEW disable/recreate
Objectifs :