v0.5.2-pre.006-fix-010

This commit is contained in:
2026-08-11 12:03:35 +02:00
parent 1ee1d0297d
commit 56572cec40
67 changed files with 2484 additions and 539 deletions

View File

@@ -0,0 +1,13 @@
<!-- file: ks-wallet-demo-scenarios/CHANGELOG.md -->
<!-- version: 1 -->
# CHANGELOG — ks-wallet-demo-scenarios
## `0.5.2-pre.006-delta-fix-010`
- crée la crate réutilisable `ks-wallet-demo-scenarios` sans dépendance vers Tauri ou le desktop ;
- ajoute `rotate_wallet_password()` pour valider quun changement de mot de passe conserve exactement lidentité publique ;
- ajoute `expect_wallet_password_rejected()` pour exiger le rejet de lancien mot de passe avec le code `wallet_native_authentication_failed` ;
- ajoute `unlock_and_sign_wallet()` pour vérifier une ouverture authentifiée et une vraie capacité de signature sans exposer le secret ;
- ajoute un test externe ordonné A → B → rejet de A → ouverture/signature avec B → restauration B → A → ouverture/signature finale avec A ;
- prépare la crate à recevoir ultérieurement les scénarios dimport/export, migration et formats tiers sans étendre `kb-app-demo-desktop`.

View File

@@ -0,0 +1,20 @@
# file: ks-wallet-demo-scenarios/Cargo.toml
# version: 1
[package]
name = "ks-wallet-demo-scenarios"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
ks-core = { path = "../ks-core" }
ks-wallet = { path = "../ks-wallet" }
[dev-dependencies]
tempfile.workspace = true
tokio.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,43 @@
<!-- file: ks-wallet-demo-scenarios/README.md -->
<!-- version: 1 -->
# ks-wallet-demo-scenarios
`ks-wallet-demo-scenarios` regroupe les scénarios dintégration réutilisables qui valident `ks-wallet` comme le ferait un consommateur externe.
## Responsabilité
La crate nimplémente ni stockage de secret, ni chiffrement, ni codec `.kswallet`. Elle dépend uniquement de lAPI publique de `ks-wallet` et orchestre des séquences vérifiables autour des invariants wallet.
La première surface couvre le cycle de mot de passe natif :
1. changement du mot de passe A vers B avec conservation de lidentité publique ;
2. vérification explicite que A est rejeté ;
3. ouverture et signature avec B ;
4. restauration du mot de passe B vers A ;
5. contrôle final douverture et de signature avec A restauré.
Les étapes publiques restent séparées afin dêtre réutilisables dans dautres scénarios. Le test dintégration les exécute dans un seul test ordonné : il ne dépend jamais de lordre dexécution de plusieurs `#[test]`.
## Frontières de sécurité
- les fonctions reçoivent des `ks_wallet::WalletPassword` déjà construits ;
- aucun mot de passe nest sérialisé, journalisé ou inclus dans les summaries ;
- les summaries contiennent uniquement lidentité publique, un code de rejet, une signature publique et la longueur du challenge lorsque ces informations sont nécessaires ;
- la création du wallet de fixture appartient au test externe et utilise un répertoire temporaire privé ;
- les fonctions de scénario ne lisent jamais directement les bytes du `.kswallet`.
## Relations
- `ks-wallet` possède le format, le secret, lauthentification et la capacité `Signer` ;
- `ks-wallet-demo-scenarios` possède uniquement lorchestration de validation ;
- `kb-app-demo-desktop` nest pas requis pour valider le cycle de mot de passe ;
- les futurs scénarios dimport/export ou de migration pourront être ajoutés ici sans alourdir lAPI Tauri.
## Documentation
- [USAGE.md](USAGE.md)
- [TODO.md](TODO.md)
- [CHANGELOG.md](CHANGELOG.md)
- [`ks-wallet`](../ks-wallet/README.md)
- [ROADMAP général](../ROADMAP.md)

View File

@@ -0,0 +1,19 @@
<!-- file: ks-wallet-demo-scenarios/TODO.md -->
<!-- version: 1 -->
# TODO — ks-wallet-demo-scenarios
## `0.5.2`
- [x] créer une crate de scénarios indépendante de Tauri et consommatrice exclusive de lAPI publique `ks-wallet` ;
- [x] ajouter une étape réutilisable de rotation de mot de passe avec conservation stricte de lalias et de la pubkey ;
- [x] ajouter une étape qui exige le rejet de lancien mot de passe par le code dauthentification natif attendu ;
- [x] ajouter une étape douverture authentifiée et de signature dun challenge non vide ;
- [x] composer un test externe ordonné A → B → rejet de A → ouverture/signature avec B → B → A → ouverture/signature avec A restauré.
## Version ultérieure non déterminée
- [ ] ajouter des scénarios de round-trip pour chaque nouveau format dimport/export supporté par `ks-wallet` ;
- [ ] ajouter des scénarios de migration legacy vers `.kswallet` lorsque plusieurs formats sources doivent être comparés ;
- [ ] ajouter des scénarios douverture explicite hors store si une validation opérateur supplémentaire devient utile ;
- [ ] najouter une CLI que si une validation manuelle hors tests apporte une valeur distincte des consommateurs existants.

View File

@@ -0,0 +1,88 @@
<!-- file: ks-wallet-demo-scenarios/USAGE.md -->
<!-- version: 1 -->
# Utilisation de ks-wallet-demo-scenarios
## Exécuter la validation intégrée
La validation autonome de la crate utilise un wallet synthétique et naltère aucun wallet réel du workspace :
```bash
cargo test -p ks-wallet-demo-scenarios
```
Le test suit un ordre déterministe dans une seule fonction : A → B, rejet de A, ouverture/signature avec B, B → A, puis ouverture/signature finale avec A.
## Réutiliser une rotation
```rust
let summary = match ks_wallet_demo_scenarios::rotate_wallet_password(
&manager,
&alias,
current_password,
replacement_password,
)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
println!("public key={}", summary.identity.public_key);
```
La fonction inspecte lidentité avant rotation, appelle `WalletManager::change_password()` puis refuse le résultat si lalias ou la pubkey a changé.
## Vérifier quun ancien mot de passe est rejeté
```rust
let rejection = match ks_wallet_demo_scenarios::expect_wallet_password_rejected(
&manager,
&alias,
old_password,
)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
assert_eq!(
rejection.rejection_code,
"wallet_native_authentication_failed",
);
```
Un échec provenant dune autre cause nest pas converti en succès du scénario. Une ouverture réussie avec le mot de passe supposé rejeté fait échouer explicitement la validation.
## Vérifier ouverture et signature
```rust
let signing = match ks_wallet_demo_scenarios::unlock_and_sign_wallet(
&manager,
&alias,
active_password,
b"wallet scenario challenge",
)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
assert!(!signing.signature.is_empty());
```
Le challenge doit être non vide. La fonction compare lidentité inspectée à lidentité authentifiée avant de signer, puis verrouille la capacité ouverte avant le retour.
## Limites actuelles
La crate ne fournit pas encore de CLI ni de gestion de secrets par variables denvironnement. Les consommateurs construisent les `WalletPassword` explicitement et les transmettent aux étapes de scénario.
Les scénarios import/export, migration legacy et formats tiers seront ajoutés lorsquils apporteront une validation supplémentaire au-delà des tests unitaires de `ks-wallet`.

View File

@@ -0,0 +1,23 @@
// file: ks-wallet-demo-scenarios/src/lib.rs
// version: 1
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
//! Reusable integration scenarios for the public `ks-wallet` boundary.
mod password_lifecycle;
/// Safe summary returned after a password is rejected as expected.
pub use self::password_lifecycle::WalletPasswordRejectionSummary;
/// Safe summary returned after a password rotation preserving wallet identity.
pub use self::password_lifecycle::WalletPasswordRotationSummary;
/// Safe summary returned after authenticated message signing.
pub use self::password_lifecycle::WalletSigningScenarioSummary;
/// Requires one supplied password to fail native wallet authentication.
pub use self::password_lifecycle::expect_wallet_password_rejected;
/// Changes one native wallet password and verifies that its public identity is preserved.
pub use self::password_lifecycle::rotate_wallet_password;
/// Authenticates one native wallet and signs an explicit non-empty challenge.
pub use self::password_lifecycle::unlock_and_sign_wallet;

View File

@@ -0,0 +1,161 @@
// file: ks-wallet-demo-scenarios/src/password_lifecycle.rs
// version: 1
//! Password lifecycle scenarios driven only through the public wallet API.
/// Safe summary returned after a password rotation preserving wallet identity.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WalletPasswordRotationSummary {
/// Persistent wallet identity retained across the password rotation.
pub identity: ks_wallet::WalletIdentity,
}
/// Safe summary returned after a password is rejected as expected.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WalletPasswordRejectionSummary {
/// Persistent wallet identity whose old password was rejected.
pub identity: ks_wallet::WalletIdentity,
/// Stable `ks-wallet` error code that proved the authentication rejection.
pub rejection_code: std::string::String,
}
/// Safe summary returned after authenticated message signing.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WalletSigningScenarioSummary {
/// Persistent wallet identity used by the signing capability.
pub identity: ks_wallet::WalletIdentity,
/// Base58 signature produced for the supplied challenge.
pub signature: std::string::String,
/// Number of challenge bytes signed by the wallet.
pub message_length: usize,
}
/// Changes one native wallet password and verifies that its public identity is preserved.
pub async fn rotate_wallet_password(
manager: &ks_wallet::WalletManager,
alias: &ks_wallet::WalletAlias,
current_password: ks_wallet::WalletPassword,
replacement_password: ks_wallet::WalletPassword,
) -> ks_core::Result<crate::WalletPasswordRotationSummary> {
let before = match manager.lookup(alias).await {
std::result::Result::Ok(std::option::Option::Some(handle)) => handle,
std::result::Result::Ok(std::option::Option::None) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_native_not_found",
"password lifecycle scenario requires an existing native wallet",
));
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let changed = match manager.change_password(alias, current_password, replacement_password).await
{
std::result::Result::Ok(handle) => handle,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if before.alias() != changed.alias() || before.public_key() != changed.public_key() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_password_rotation_identity_changed",
"password rotation changed the native wallet public identity",
));
}
return std::result::Result::Ok(crate::WalletPasswordRotationSummary {
identity: changed.identity(),
});
}
/// Requires one supplied password to fail native wallet authentication.
pub async fn expect_wallet_password_rejected(
manager: &ks_wallet::WalletManager,
alias: &ks_wallet::WalletAlias,
rejected_password: ks_wallet::WalletPassword,
) -> ks_core::Result<crate::WalletPasswordRejectionSummary> {
let handle = match manager.lookup(alias).await {
std::result::Result::Ok(std::option::Option::Some(handle)) => handle,
std::result::Result::Ok(std::option::Option::None) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_native_not_found",
"password rejection scenario requires an existing native wallet",
));
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let rejection = match manager.unlock(alias, rejected_password).await {
std::result::Result::Ok(wallet) => {
let accepted_identity = wallet.identity();
wallet.lock();
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_rejected_password_accepted",
format!(
"password expected to be rejected authenticated wallet alias {} with public key {}",
accepted_identity.alias.as_str(),
accepted_identity.public_key
),
));
},
std::result::Result::Err(error) => error,
};
if rejection.code() != "wallet_native_authentication_failed" {
return std::result::Result::Err(rejection);
}
return std::result::Result::Ok(crate::WalletPasswordRejectionSummary {
identity: handle.identity(),
rejection_code: rejection.code().to_owned(),
});
}
/// Authenticates one native wallet and signs an explicit non-empty challenge.
pub async fn unlock_and_sign_wallet(
manager: &ks_wallet::WalletManager,
alias: &ks_wallet::WalletAlias,
password: ks_wallet::WalletPassword,
challenge: &[u8],
) -> ks_core::Result<crate::WalletSigningScenarioSummary> {
if challenge.is_empty() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_signing_challenge_empty",
"wallet signing scenario requires a non-empty challenge",
));
}
let expected = match manager.lookup(alias).await {
std::result::Result::Ok(std::option::Option::Some(handle)) => handle,
std::result::Result::Ok(std::option::Option::None) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_native_not_found",
"wallet signing scenario requires an existing native wallet",
));
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wallet = match manager.unlock(alias, password).await {
std::result::Result::Ok(wallet) => wallet,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let identity = wallet.identity();
if &identity.alias != expected.alias() || identity.public_key.as_str() != expected.public_key()
{
wallet.lock();
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_unlocked_identity_mismatch",
"authenticated wallet identity differs from the inspected native wallet",
));
}
let signature = match wallet.sign_message(challenge) {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => {
wallet.lock();
return std::result::Result::Err(error);
},
};
wallet.lock();
if signature.is_empty() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_demo_signature_empty",
"authenticated wallet produced an empty signature",
));
}
return std::result::Result::Ok(crate::WalletSigningScenarioSummary {
identity,
signature,
message_length: challenge.len(),
});
}

View File

@@ -0,0 +1,90 @@
// file: ks-wallet-demo-scenarios/tests/password_lifecycle.rs
// version: 1
//! External ordered password lifecycle scenario.
const PASSWORD_A: &str = "ks-wallet-demo-password-A";
const PASSWORD_B: &str = "ks-wallet-demo-password-B";
const CHALLENGE_B: &[u8] = b"ks-wallet-demo-scenarios password-B signing proof";
const CHALLENGE_A_RESTORED: &[u8] = b"ks-wallet-demo-scenarios restored password-A signing proof";
fn make_directory_private(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else(
|error| panic!("wallet scenario directory permissions must be set: {error}"),
);
}
}
fn password(value: &str) -> ks_wallet::WalletPassword {
return ks_wallet::WalletPassword::new(value.to_owned())
.unwrap_or_else(|error| panic!("scenario password must be accepted: {error}"));
}
#[tokio::test]
async fn password_rotation_runs_a_to_b_rejects_a_then_restores_a() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary scenario directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("wallet manager must be created: {error}"));
let alias = ks_wallet::WalletAlias::parse("password-lifecycle")
.unwrap_or_else(|error| panic!("scenario alias must be valid: {error}"));
let created = manager
.create(alias.clone(), password(PASSWORD_A))
.await
.unwrap_or_else(|error| panic!("scenario wallet creation must succeed: {error}"));
let initial_public_key = created.public_key();
created.lock();
let rotated_to_b = ks_wallet_demo_scenarios::rotate_wallet_password(
&manager,
&alias,
password(PASSWORD_A),
password(PASSWORD_B),
)
.await
.unwrap_or_else(|error| panic!("password A to B rotation must succeed: {error}"));
assert_eq!(rotated_to_b.identity.public_key, initial_public_key);
let rejected_a = ks_wallet_demo_scenarios::expect_wallet_password_rejected(
&manager,
&alias,
password(PASSWORD_A),
)
.await
.unwrap_or_else(|error| panic!("old password A must be rejected: {error}"));
assert_eq!(rejected_a.identity.public_key, initial_public_key);
assert_eq!(rejected_a.rejection_code, "wallet_native_authentication_failed");
let signed_with_b = ks_wallet_demo_scenarios::unlock_and_sign_wallet(
&manager,
&alias,
password(PASSWORD_B),
CHALLENGE_B,
)
.await
.unwrap_or_else(|error| panic!("password B must unlock and sign: {error}"));
assert_eq!(signed_with_b.identity.public_key, initial_public_key);
assert_eq!(signed_with_b.message_length, CHALLENGE_B.len());
assert!(!signed_with_b.signature.is_empty());
let restored_to_a = ks_wallet_demo_scenarios::rotate_wallet_password(
&manager,
&alias,
password(PASSWORD_B),
password(PASSWORD_A),
)
.await
.unwrap_or_else(|error| panic!("password B to A restoration must succeed: {error}"));
assert_eq!(restored_to_a.identity.public_key, initial_public_key);
let signed_with_restored_a = ks_wallet_demo_scenarios::unlock_and_sign_wallet(
&manager,
&alias,
password(PASSWORD_A),
CHALLENGE_A_RESTORED,
)
.await
.unwrap_or_else(|error| panic!("restored password A must unlock and sign: {error}"));
assert_eq!(signed_with_restored_a.identity.public_key, initial_public_key);
assert_eq!(signed_with_restored_a.message_length, CHALLENGE_A_RESTORED.len());
assert!(!signed_with_restored_a.signature.is_empty());
}