v0.2.6-pre.010-fix.001

This commit is contained in:
2026-08-21 14:53:50 +02:00
parent 4dcdbfaa55
commit 6dcd278459
13 changed files with 280 additions and 45 deletions

View File

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

View File

@@ -328,9 +328,9 @@
</div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Rotation credentials OWNER</div>
<div class="card-header fw-semibold">Rotation credentials OWNER / VIEW</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>
<p class="small text-body-secondary">OWNER peut administrer OWNER et VIEW sans ancien password VIEW. Une session VIEW peut self-rotate son propre password VIEW. Les rotations modifient le fichier Wallet sans changer 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>
@@ -351,7 +351,7 @@
</div>
</div>
</div>
<div id="rotationStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session OWNER requise.</div>
<div id="rotationStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session VIEW ou OWNER requise pour une rotation VIEW ; OWNER requis pour une rotation OWNER.</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/frontend/ts/main.ts
// version: 10
// version: 11
import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5";
@@ -234,12 +234,13 @@ function updateOwnerMetadataActions(): void {
function updateSecurityRotationActions(): void {
const ownerOpen = activeSessionState === "owner_open" && authorizedWalletProjection?.capability === "owner";
const viewOpen = activeSessionState === "view_open" && authorizedWalletProjection?.capability === "view";
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;
control.disabled = (!ownerOpen && !viewOpen) || !activeViewEnabled;
});
}
@@ -442,8 +443,8 @@ function renderAuthorizedWallet(wallet: WalletAuthorizedDto): void {
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.",
? "OWNER ouvert. Rotation OWNER et administration VIEW disponibles ; les secrets Config restent inchangés."
: "VIEW ouvert. Self-rotation VIEW disponible ; OWNER reste inaccessible.",
);
updateSessionActions();
frontendDebug("main", "Authorized Wallet session rendered", {
@@ -561,6 +562,25 @@ async function recoverOwnerStateConflict(walletId: string): Promise<void> {
}
}
async function recoverRotationStateConflict(walletId: string, capability: UnlockCapability): Promise<void> {
clearAuthorizedProjection();
clearBalanceProjection();
const request: WalletSelectionRequestDto = { walletId };
try {
const wallet = await invokeKsp<LockedWalletDto>("main", "select_wallet", { request });
renderLockedWallet(wallet);
setText("#rotationStatus", `Conflit détat : rotation ${capability.toUpperCase()} interrompue, wallet reverrouillé.`);
setText("#unlockStatus", `Conflit détat détecté : réautoriser ${capability.toUpperCase()} explicitement avant une nouvelle rotation.`);
await loadWalletInventory("list_wallets", false);
activateView("security", "user");
frontendWarn("main", "Wallet state conflict forced credential reauthorization", { capability, walletId });
} catch {
clearSelectedWallet();
setText("#rotationStatus", "Conflit détat détecté ; la réinspection du wallet a échoué.");
frontendWarn("main", "Wallet credential rotation state conflict recovery failed", { capability, walletId });
}
}
async function runOwnerMetadataMutation(
command: "add_wallet_note" | "delete_wallet_note" | "update_wallet_alias" | "update_wallet_note",
args: Record<string, unknown>,
@@ -963,9 +983,12 @@ async function unlockConfigured(capability: UnlockCapability): Promise<void> {
}
async function rotateWalletPassword(capability: UnlockCapability): Promise<void> {
if (activeSessionState !== "owner_open" || !activeWalletId || authorizedWalletProjection?.capability !== "owner") {
const sessionCapability = authorizedWalletProjection?.capability;
const ownerAuthorized = activeSessionState === "owner_open" && sessionCapability === "owner";
const viewAuthorized = activeSessionState === "view_open" && sessionCapability === "view";
if (!activeWalletId || (capability === "owner" && !ownerAuthorized) || (capability === "view" && !ownerAuthorized && !viewAuthorized)) {
clearRotationSensitiveInputs();
setText("#rotationStatus", "Session OWNER requise.");
setText("#rotationStatus", capability === "owner" ? "Session OWNER requise." : "Session VIEW ou OWNER requise.");
return;
}
if (capability === "view" && !activeViewEnabled) {
@@ -1012,13 +1035,13 @@ async function rotateWalletPassword(capability: UnlockCapability): Promise<void>
} catch (caughtError) {
clearRotationSensitiveInputs();
if (isWalletStateConflict(caughtError)) {
await recoverOwnerStateConflict(walletId);
await recoverRotationStateConflict(walletId, sessionCapability === "view" ? "view" : "owner");
return;
}
if (activeWalletId === walletId) {
activeSessionState = "owner_open";
setText("#currentWalletState", "OWNER open");
setText("#rotationStatus", "Rotation refusée ; la session OWNER reste ouverte.");
if (activeWalletId === walletId && sessionCapability) {
activeSessionState = sessionCapability === "view" ? "view_open" : "owner_open";
setText("#currentWalletState", `${sessionCapability.toUpperCase()} open`);
setText("#rotationStatus", `Rotation refusée ; la session ${sessionCapability.toUpperCase()} reste ouverte.`);
updateSessionActions();
}
frontendWarn("main", "Wallet credential rotation failed", { capability, walletId });

View File

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

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 13
// version: 14
//! Shared backend state owned by the Wallet Desk Tauri application.
@@ -483,6 +483,18 @@ impl AppState {
"Wallet unlock operation is already in progress",
));
},
crate::WalletSession::ViewOperation { wallet_id, path, pubkey } => {
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
*session = crate::WalletSession::ViewOperation { wallet_id, path, pubkey };
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet VIEW privileged operation is already in progress",
));
},
crate::WalletSession::OwnerOperation { wallet_id, path, pubkey, view_enabled } => {
let session = self.wallet_session.lock();
let mut session = match session {
@@ -722,23 +734,146 @@ impl AppState {
return self.finish_owner_operation(context, result).await;
}
/// Rotates the enabled VIEW password from the current OWNER session without requiring the previous VIEW password.
/// Rotates the enabled VIEW password from either VIEW self-service or OWNER administration.
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 owner_context = self.begin_owner_operation("view_password_rotate");
if let std::result::Result::Ok(mut context) = owner_context {
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;
}
let context = self.begin_view_operation("view_password_self_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;
let result = context.view.rotate_view_password(context.path.as_path(), password).await;
return self.finish_view_operation(context, result).await;
}
fn begin_view_operation(&self, operation: &'static str) -> ksp_core_lib::Result<ViewOperationContext> {
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
let (wallet_id, path, view) = match previous {
crate::WalletSession::View { wallet_id, path, wallet } => (wallet_id, path, wallet),
other => {
*session = other;
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet VIEW self-service operation requires an authorized VIEW session",
));
},
};
let pubkey = view.pubkey().to_owned();
let context = ViewOperationContext { operation, path: path.clone(), pubkey, view, wallet_id: wallet_id.clone() };
*session = crate::WalletSession::ViewOperation { wallet_id, path, pubkey };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = context.wallet_id.as_str(), operation, "Wallet VIEW privileged operation started");
return std::result::Result::Ok(context);
}
async fn finish_view_operation(
&self,
context: ViewOperationContext,
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_view_state_conflict(context).await;
return std::result::Result::Err(error);
}
self.restore_view_after_operation_failure(context);
return std::result::Result::Err(error);
}
return self.install_view_after_operation_success(context);
}
fn install_view_after_operation_success(&self, context: ViewOperationContext) -> ksp_core_lib::Result<crate::WalletAuthorizedDto> {
let ViewOperationContext { operation, path, pubkey, view, wallet_id } = context;
let configured_secret_candidate_count = self.secret_candidate_count_or_zero(wallet_id.as_str());
let dto = crate::view_projection(wallet_id.as_str(), true, configured_secret_candidate_count, &view);
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::ViewOperation { wallet_id: reserved_wallet_id, path: reserved_path, pubkey: reserved_pubkey }
if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey =>
{
*session = crate::WalletSession::View { wallet_id: wallet_id.clone(), path, wallet: view };
ksp_logging_lib::info!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet VIEW privileged operation completed");
return std::result::Result::Ok(dto);
},
other => {
*session = other;
drop(view);
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet VIEW operation completion no longer owns the selected session",
));
},
}
}
fn restore_view_after_operation_failure(&self, context: ViewOperationContext) {
let ViewOperationContext { operation, path, pubkey, view, wallet_id } = context;
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::ViewOperation { wallet_id: reserved_wallet_id, path: reserved_path, pubkey: reserved_pubkey }
if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey =>
{
*session = crate::WalletSession::View { wallet_id: wallet_id.clone(), path, wallet: view };
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_SESSION, wallet_id = wallet_id.as_str(), operation, "Wallet VIEW privileged operation failed without invalidating the authenticated handle");
},
other => {
*session = other;
drop(view);
},
}
}
async fn recover_view_state_conflict(&self, context: ViewOperationContext) {
let ViewOperationContext { operation, path, pubkey, view, wallet_id } = context;
drop(view);
let inspected = ksp_wallet_lib::inspect_locked_wallet_file_v1(path.as_path()).await;
let session = self.wallet_session.lock();
let mut session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let previous = std::mem::replace(&mut *session, crate::WalletSession::no_selection());
match previous {
crate::WalletSession::ViewOperation { wallet_id: reserved_wallet_id, path: reserved_path, pubkey: reserved_pubkey }
if reserved_wallet_id == wallet_id && reserved_path == path && reserved_pubkey == pubkey =>
{
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 VIEW state conflict purged the stale handle and forced reauthorization");
},
other => {
*session = other;
return;
},
}
}
fn begin_owner_operation(&self, operation: &'static str) -> ksp_core_lib::Result<OwnerOperationContext> {
@@ -920,7 +1055,7 @@ impl AppState {
crate::WalletSession::Owner { wallet_id, wallet, .. } => {
std::result::Result::Ok(WalletBalanceContext { capability: "owner", pubkey: wallet.pubkey().to_owned(), wallet_id: wallet_id.clone() })
},
crate::WalletSession::OwnerOperation { .. } | crate::WalletSession::PrivilegedOperation { .. } => {
crate::WalletSession::ViewOperation { .. } | crate::WalletSession::OwnerOperation { .. } | crate::WalletSession::PrivilegedOperation { .. } => {
std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_SESSION_INVALID,
"Wallet balance is unavailable while a privileged Wallet operation is running",
@@ -948,6 +1083,7 @@ impl AppState {
},
crate::WalletSession::NoSelection
| crate::WalletSession::Locked { .. }
| crate::WalletSession::ViewOperation { .. }
| crate::WalletSession::OwnerOperation { .. }
| crate::WalletSession::PrivilegedOperation { .. } => false,
};
@@ -995,6 +1131,14 @@ 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 ViewOperationContext {
operation: &'static str,
path: std::path::PathBuf,
pubkey: ksp_core_lib::Pubkey,
view: std::boxed::Box<ksp_wallet_lib::WalletView>,
wallet_id: String,
}
struct OwnerOperationContext {
operation: &'static str,
owner: std::boxed::Box<ksp_wallet_lib::WalletOwner>,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 10
// version: 11
//! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -200,7 +200,7 @@ 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.
/// New credential request moved frontend -> Rust only for one explicit VIEW/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;

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-app-wallet-desk/src/wallet_security.rs
// version: 1
// version: 2
//! OWNER-only credential rotation contracts for Wallet Desk.
//! VIEW/OWNER credential rotation contracts for Wallet Desk.
use ts_rs::TS; // rust-rules: trait-import

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/wallet_session.rs
// version: 6
// version: 7
//! Durable root-scoped Wallet session lifecycle for Wallet Desk.
@@ -14,7 +14,7 @@ pub(crate) enum WalletSessionStateDto {
NoSelection,
/// One Wallet is selected but remains locked.
Locked,
/// One explicit unlock operation is running in Rust.
/// One explicit privileged Wallet operation is running in Rust.
PrivilegedOperation,
/// One Wallet is open with VIEW capability.
ViewOpen,
@@ -150,6 +150,15 @@ pub(crate) enum WalletSession {
/// Authorized VIEW handle retaining metadata capability only in Rust.
wallet: std::boxed::Box<ksp_wallet_lib::WalletView>,
},
/// One VIEW-authorized self-service mutation temporarily owns the handle outside the session mutex.
ViewOperation {
/// Native Wallet filename / root-scoped identifier.
wallet_id: String,
/// Full root-scoped filesystem path retained only in Rust.
path: std::path::PathBuf,
/// Authorized Solana identity used only to reject stale completion.
pubkey: ksp_core_lib::Pubkey,
},
/// One Wallet kept open with OWNER capability.
Owner {
/// Native Wallet filename / root-scoped identifier.
@@ -208,6 +217,11 @@ impl WalletSession {
state: WalletSessionStateDto::ViewOpen,
wallet_id: std::option::Option::Some(wallet_id.clone()),
},
Self::ViewOperation { wallet_id, .. } => WalletSessionDto {
filename: std::option::Option::Some(wallet_id.clone()),
state: WalletSessionStateDto::PrivilegedOperation,
wallet_id: std::option::Option::Some(wallet_id.clone()),
},
Self::Owner { wallet_id, .. } => WalletSessionDto {
filename: std::option::Option::Some(wallet_id.clone()),
state: WalletSessionStateDto::OwnerOpen,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Wallet Desk",
"version": "0.2.6-pre.10",
"version": "0.2.6-pre.10.fix.1",
"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: 10
// version: 11
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
@@ -255,7 +255,7 @@ fn pre_009_owner_metadata_administration_uses_wallet_owner_and_refreshes_project
}
#[test]
fn pre_010_owner_authorized_credential_rotations_preserve_session_projection() {
fn pre_010_owner_and_view_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());
@@ -265,6 +265,9 @@ fn pre_010_owner_authorized_credential_rotations_preserve_session_projection() {
assert!(security.contains("WalletPasswordRotationRequestDto"));
assert!(state.contains("owner.rotate_owner_password"));
assert!(state.contains("owner.rotate_view_password"));
assert!(state.contains("view.rotate_view_password"));
assert!(state.contains("begin_view_operation"));
assert!(state.contains("finish_view_operation"));
assert!(state.contains("begin_owner_operation"));
assert!(state.contains("finish_owner_operation"));
assert!(tauri.contains("rotate_owner_password"));
@@ -273,5 +276,6 @@ fn pre_010_owner_authorized_credential_rotations_preserve_session_projection() {
assert!(html.contains("id=\"rotateViewPasswordSubmit\""));
assert!(main.contains("rotateWalletPassword"));
assert!(main.contains("bindRotationActions"));
assert!(main.contains("viewAuthorized"));
assert!(main.contains("Les secrets Config"));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/tests/desktop_security.rs
// version: 10
// version: 11
//! Static desktop security contracts for the Wallet Desk pre.002 shell.
@@ -207,8 +207,12 @@ fn rotation_passwords_are_request_only_confirmed_frontend_side_and_never_logged(
assert!(!fields.contains("confirm"));
assert!(state.contains("OwnerPassword::new(request.password)"));
assert!(state.contains("ViewPassword::new(request.password)"));
assert!(state.contains("view.rotate_view_password"));
assert!(state.contains("owner.rotate_view_password"));
assert!(main.contains("password.value !== confirmation.value"));
assert!(main.contains("clearRotationSensitiveInputs"));
assert!(main.contains("viewAuthorized"));
assert!(main.contains("recoverRotationStateConflict"));
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,42 @@
<!-- file: deltas/0.2.6/pre.010-fix.001.md -->
<!-- version: 1 -->
# Delta `0.2.6-pre.010-fix.001` — self-rotation VIEW
## Objet
Compléter `pre.010` pour respecter le contrat de capacité de `ksp-wallet-lib` : un détenteur VIEW déjà autorisé doit pouvoir changer son propre password VIEW, tandis qu'OWNER conserve son pouvoir administratif de rotation VIEW sans ancien password VIEW.
## Changements
- `workspace.package.version` passe à `0.2.6-pre.10.fix.1` ;
- ajout de l'état Rust-only `WalletSession::ViewOperation` pour déplacer un `WalletView` hors mutex pendant Argon2/persistence ;
- `rotate_view_password` accepte désormais une session VIEW ou OWNER :
- VIEW -> `WalletView::rotate_view_password` -> session VIEW conservée ;
- OWNER -> `WalletOwner::rotate_view_password` -> session OWNER conservée ;
- `wallet.state_conflict` côté VIEW détruit le handle stale, réinspecte le fichier et force une nouvelle autorisation VIEW ;
- Lock/balance considèrent `ViewOperation` comme une opération privilégiée en cours ;
- UI : contrôles Rotate VIEW actifs en VIEW comme en OWNER, Rotate OWNER reste strictement OWNER-only ;
- la récupération frontend de conflit de rotation exige la réautorisation de la capacité initiatrice ;
- canaries contract/security étendus aux deux chemins de rotation VIEW ;
- aucun secret Config n'est modifié implicitement.
## Hors changement
- aucun changement du format `.kspwallet` ;
- aucun changement dans `ksp-wallet-lib` ;
- aucune nouvelle dépendance ;
- `ROADMAP.md`, `CHANGELOG.md` et `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` restent inchangés.
## 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
```
Runtime utile : ouvrir VIEW, Rotate VIEW, Lock, vérifier ancien VIEW refusé / nouveau VIEW accepté ; puis ouvrir OWNER et vérifier que Rotate VIEW et Rotate OWNER restent fonctionnels.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md -->
<!-- version: 24 -->
<!-- version: 25 -->
# Plan `0.2.6` — Wallet Desk
@@ -1515,14 +1515,16 @@ Objectifs réalisés :
```text
rotate OWNER password depuis une session OWNER
rotate VIEW password depuis une session OWNER lorsque VIEW est activé
self-rotate VIEW password depuis une session VIEW autorisée
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
wallet.state_conflict => purge du handle stale + retour Locked + re-unlock de la capacité initiatrice
session OWNER ou VIEW 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
VIEW rotation administrative sans ancien password VIEW conformément au contrat WalletOwner
VIEW self-rotation conformément au contrat WalletView
secrets Config volontairement inchangés après rotation
message UI explicite avant Lock/unlock configuré
redaction/canaries request-only
@@ -1530,7 +1532,9 @@ 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.
`rotate_owner_password` retourne une projection OWNER. `rotate_view_password` conserve la capacité initiatrice : projection OWNER pour l'administration OWNER, projection VIEW pour la self-rotation VIEW. Aucun credential n'est réexposé. Le password de confirmation reste strictement local au frontend et n'est jamais envoyé à Rust.
Le correctif `pre.010-fix.001` complète ce contrat : la personne déjà autorisée en VIEW peut changer son propre password VIEW via `WalletView::rotate_view_password`, tandis qu'OWNER conserve le pouvoir administratif de remplacer le password VIEW sans connaître l'ancien. Wallet Desk réserve `ViewOperation` pendant l'Argon2/persistence async, refuse les complétions stale et applique au chemin VIEW la même politique de recovery `wallet.state_conflict` que pour OWNER.
### `pre.011` — strong VIEW disable/recreate