0.5.1-pre.002
This commit is contained in:
43
ks-wallet/CHANGELOG.md
Normal file
43
ks-wallet/CHANGELOG.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<!-- file: ks-wallet/CHANGELOG.md -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# CHANGELOG — ks-wallet
|
||||
|
||||
## `0.5.1-pre.002`
|
||||
|
||||
- renomme `kb-wallet` en `ks-wallet` et `kb_wallet` en `ks_wallet` ;
|
||||
- aligne le target racine de tracing et les consommateurs sans modifier le format persistant des wallets, réservé à `0.5.2`.
|
||||
|
||||
## `0.5.0-pre.002`
|
||||
|
||||
- caractérise le format persistant `0.4.8` comme format legacy à importer plutôt qu’à modifier in-place ;
|
||||
- prépare pour `0.5.2` un conteneur versionné, une migration atomique/rollback et la séparation identité/secret/verrouillage/signature ;
|
||||
- renforce le TODO sur l’absence de propagation de secrets vers configuration, logs, erreurs, diagnostics ou Tauri.
|
||||
|
||||
## `0.4.8-pre.013`
|
||||
|
||||
- `pre.013-delta-fix-021` ajoute `TemporaryWallet::as_sync_signer()` afin de préserver le marqueur `Sync` du `solana_keypair::Keypair` lorsque le signataire traverse un futur async qui doit être `Send` ;
|
||||
- conserve `as_signer()` inchangé pour les consommateurs historiques et n’expose toujours aucun octet secret.
|
||||
|
||||
## 0.4.6
|
||||
|
||||
- alignement de la crate sur la version fonctionnelle bot3 `0.4.6` ;
|
||||
- clôture des tâches de migration applicables et report explicite des évolutions ultérieures dans le TODO.
|
||||
|
||||
## 0.1.0-pre.072
|
||||
|
||||
- reclassement du TODO selon les blocants avant `0.4.6`, les travaux `0.4.7`, les versions ultérieures et les dépendances conditionnelles.
|
||||
|
||||
## 0.1.0-pre.071
|
||||
|
||||
- réécriture du README avec indication explicite du statut d’ébauche ;
|
||||
- ajout du TODO détaillant les fonctionnalités prévues pour `0.5.x` ;
|
||||
- ajout du guide d’utilisation et de plusieurs exemples publics ;
|
||||
- clarification de la frontière entre signer, politique et exécution.
|
||||
|
||||
## 0.1.0-pre.062
|
||||
|
||||
- migration et renommage du wallet temporaire vers `ks-wallet` ;
|
||||
- conservation des alias validés, keypairs temporaires et stockage local ;
|
||||
- ajout des contrôles de permissions, type de fichier et effacement des buffers secrets ;
|
||||
- adaptation aux normes Rust 2024 et Khadhroony bot3.
|
||||
24
ks-wallet/Cargo.toml
Normal file
24
ks-wallet/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
# file: ks-wallet/Cargo.toml
|
||||
# version: 3
|
||||
|
||||
[package]
|
||||
name = "ks-wallet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ks-core = { path = "../ks-core" }
|
||||
serde_json.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
solana-signer.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
46
ks-wallet/README.md
Normal file
46
ks-wallet/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- file: ks-wallet/README.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# ks-wallet
|
||||
|
||||
`ks-wallet` fournit actuellement une frontière locale minimale de portefeuille pour les démonstrations et tests d’intégration.
|
||||
|
||||
## État actuel
|
||||
|
||||
La crate est une ébauche fonctionnelle, pas un gestionnaire de wallets complet.
|
||||
|
||||
Elle fournit :
|
||||
|
||||
- alias validés ;
|
||||
- wallet temporaire en mémoire ;
|
||||
- stockage local JSON d’un keypair Solana ;
|
||||
- chargement ou création atomique ;
|
||||
- résumé non secret ;
|
||||
- accès au trait `Signer` sans exposition des octets ;
|
||||
- signature de messages ;
|
||||
- permissions Unix privées et contrôles de fichiers ;
|
||||
- effacement des buffers secrets utilisés lors de la lecture ou écriture.
|
||||
|
||||
## Hors capacités actuelles
|
||||
|
||||
Les fonctions suivantes ne sont pas encore implémentées :
|
||||
|
||||
- chiffrement par mot de passe ;
|
||||
- plusieurs wallets gérés comme collection ;
|
||||
- sélection persistante du wallet actif ;
|
||||
- import/export contrôlé ;
|
||||
- changement de mot de passe ;
|
||||
- verrouillage et déverrouillage ;
|
||||
- sauvegarde et restauration ;
|
||||
- politiques de sécurité complètes.
|
||||
|
||||
## Relations
|
||||
|
||||
`ks-wallet` fournit un signer. Les limites de dépense, la simulation, l’autorisation opérateur et l’envoi sont gérés par `ks-lib`, `ks-pipeline-demo-scenarios` et les applications.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [USAGE.md](USAGE.md)
|
||||
- [TODO.md](TODO.md)
|
||||
- [CHANGELOG.md](CHANGELOG.md)
|
||||
- [ROADMAP général](../ROADMAP.md)
|
||||
19
ks-wallet/TODO.md
Normal file
19
ks-wallet/TODO.md
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- file: ks-wallet/TODO.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# TODO — ks-wallet
|
||||
|
||||
## Série `0.5.x`
|
||||
|
||||
- [ ] `0.5.2` - caractériser le format legacy `<alias>.json` contenant le tableau JSON standard du keypair Solana.
|
||||
- [ ] `0.5.2` - définir un conteneur persistant versionné avant d’introduire le chiffrement.
|
||||
- [ ] `0.5.2` - séparer identité publique, matériau secret, état verrouillé/déverrouillé et capacité de signature.
|
||||
- [ ] Migration - définir import legacy, écriture atomique, rollback et conservation de la clé publique.
|
||||
- [ ] Fonctionnalité - gérer plusieurs wallets persistants et la sélection du wallet actif si retenue.
|
||||
- [ ] Sécurité - ajouter chiffrement/déchiffrement et changement de secret seulement après validation du format.
|
||||
- [ ] Sécurité - ajouter verrouillage/déverrouillage explicites et invalidation de session.
|
||||
- [ ] Fonctionnalité - ajouter import/export, sauvegarde/restauration avec contrats de sécurité explicites.
|
||||
- [ ] Sécurité - interdire tout secret wallet dans config générale, logs, erreurs, diagnostics ou Tauri.
|
||||
- [ ] Intégration - relier la sélection du wallet aux profils et signers sans exposer les octets secrets.
|
||||
- [ ] Tests - ajouter une API externe de caractérisation, corruption, concurrence, récupération et migration.
|
||||
- [ ] Documentation - produire un guide de sécurité avant tout usage hors démonstration.
|
||||
184
ks-wallet/USAGE.md
Normal file
184
ks-wallet/USAGE.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<!-- file: ks-wallet/USAGE.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# Utilisation de ks-wallet
|
||||
|
||||
## Objectif
|
||||
|
||||
La crate fournit des wallets temporaires en mémoire ou persistés localement pour les démonstrations et tests d’intégration.
|
||||
|
||||
## Valider un alias
|
||||
|
||||
```rust
|
||||
let alias = match ks_wallet::WalletAlias::parse(
|
||||
"devnet-operator",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(alias.as_str(), "devnet-operator");
|
||||
```
|
||||
|
||||
Un alias doit contenir entre 1 et 64 octets, commencer par un caractère alphanumérique ASCII et ne contenir ensuite que des caractères alphanumériques, `_` ou `-`.
|
||||
|
||||
## Générer un wallet en mémoire
|
||||
|
||||
```rust
|
||||
let alias = match ks_wallet::WalletAlias::parse(
|
||||
"ephemeral",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
let wallet =
|
||||
ks_wallet::TemporaryWallet::generate(alias);
|
||||
let summary = wallet.summary();
|
||||
|
||||
assert!(summary.storage_path.is_none());
|
||||
println!("public key={}", summary.public_key);
|
||||
```
|
||||
|
||||
## Signer un message
|
||||
|
||||
```rust
|
||||
let signature = match wallet.sign_message(
|
||||
b"khadhroony demo authorization",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
println!("signature={signature}");
|
||||
```
|
||||
|
||||
`as_signer()` retourne une référence au trait Solana `Signer` sans exposer les octets du keypair.
|
||||
|
||||
Pour une API async qui conserve la référence au travers d’un `await` et impose un futur `Send`, `as_sync_signer()` préserve explicitement le marqueur `Sync` du keypair concret. Cette vue ne change ni la clé utilisée ni les règles d’autorisation de la couche d’exécution.
|
||||
|
||||
## Créer un stockage local
|
||||
|
||||
```rust
|
||||
let store = match ks_wallet::TemporaryWalletStore::new(
|
||||
"./data/wallets",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
println!(
|
||||
"wallet directory={}",
|
||||
store.directory().display()
|
||||
);
|
||||
```
|
||||
|
||||
## Créer un wallet persistant
|
||||
|
||||
```rust
|
||||
let alias = match ks_wallet::WalletAlias::parse(
|
||||
"devnet-payer",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
let wallet = match store.create(alias).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
let summary = wallet.summary();
|
||||
assert!(summary.storage_path.is_some());
|
||||
```
|
||||
|
||||
`create` refuse d’écraser un fichier existant.
|
||||
|
||||
## Charger ou créer atomiquement
|
||||
|
||||
```rust
|
||||
let alias = match ks_wallet::WalletAlias::parse(
|
||||
"shared-devnet",
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
let wallet = match store.load_or_create(alias).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
println!("wallet={}", wallet.public_key());
|
||||
```
|
||||
|
||||
## Vérifier l’existence et le chemin
|
||||
|
||||
```rust
|
||||
let path = store.wallet_path(&alias);
|
||||
let exists = match store.exists(&alias).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
println!("path={}, exists={exists}", path.display());
|
||||
```
|
||||
|
||||
## Politique non secrète
|
||||
|
||||
```rust
|
||||
let policy = ks_wallet::WalletPolicy {
|
||||
signing_enabled: true,
|
||||
lamport_spend_limit: std::option::Option::Some(
|
||||
1_000_000,
|
||||
),
|
||||
};
|
||||
|
||||
assert!(policy.signing_enabled);
|
||||
```
|
||||
|
||||
`WalletPolicy` transporte une intention de politique. Son application effective appartient à la couche d’exécution.
|
||||
|
||||
## Erreurs et invariants
|
||||
|
||||
- les octets secrets ne sont jamais exposés par l’API publique ;
|
||||
- les résumés ne contiennent que l’alias, la clé publique et le chemin ;
|
||||
- un fichier existant n’est pas écrasé ;
|
||||
- les liens symboliques et fichiers non réguliers sont refusés ;
|
||||
- sur Unix, les permissions privées sont vérifiées ;
|
||||
- les buffers secrets temporaires sont effacés ;
|
||||
- cette crate ne décide pas si une transaction est autorisée.
|
||||
|
||||
## Tests de référence
|
||||
|
||||
- validation des alias ;
|
||||
- génération et signature sans persistance ;
|
||||
- création, chargement et `load_or_create` ;
|
||||
- refus d’écrasement ;
|
||||
- rejet des keypairs corrompus ;
|
||||
- vérification des permissions Unix privées ;
|
||||
- rejet des liens symboliques et permissions trop ouvertes.
|
||||
|
||||
## Limites durables
|
||||
|
||||
- le format persistant actuel est le tableau JSON standard du keypair Solana ;
|
||||
- la crate ne signe pas automatiquement une transaction ;
|
||||
- elle ne transmet aucun secret à une interface frontend.
|
||||
9
ks-wallet/src/constants.rs
Normal file
9
ks-wallet/src/constants.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// file: ks-wallet/src/constants.rs
|
||||
// version: 2
|
||||
|
||||
//! Local constants for the `ks-wallet` crate.
|
||||
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "ks-wallet";
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64;
|
||||
26
ks-wallet/src/lib.rs
Normal file
26
ks-wallet/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// file: ks-wallet/src/lib.rs
|
||||
// version: 3
|
||||
|
||||
//! Wallet boundary for local key storage and transaction signing.
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod constants;
|
||||
mod wallet;
|
||||
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
|
||||
/// Solana keypair kept private inside the wallet boundary.
|
||||
pub use self::wallet::TemporaryWallet;
|
||||
/// Filesystem-backed store for development and integration-test wallets.
|
||||
pub use self::wallet::TemporaryWalletStore;
|
||||
/// Validated non-secret wallet alias.
|
||||
pub use self::wallet::WalletAlias;
|
||||
/// Non-secret wallet policy.
|
||||
pub use self::wallet::WalletPolicy;
|
||||
/// Non-secret wallet description.
|
||||
pub use self::wallet::WalletSummary;
|
||||
551
ks-wallet/src/wallet.rs
Normal file
551
ks-wallet/src/wallet.rs
Normal file
@@ -0,0 +1,551 @@
|
||||
// file: ks-wallet/src/wallet.rs
|
||||
// version: 10
|
||||
|
||||
//! Local wallet storage and signing primitives.
|
||||
|
||||
use solana_signer::Signer; // rust-rules: trait-import
|
||||
use zeroize::Zeroize; // rust-rules: trait-import
|
||||
|
||||
/// Validated non-secret wallet alias used as a local filename stem.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WalletAlias(std::string::String);
|
||||
|
||||
impl crate::WalletAlias {
|
||||
/// Parses and validates a wallet alias.
|
||||
pub fn parse(value: impl std::convert::Into<std::string::String>) -> ks_core::Result<Self> {
|
||||
let value = value.into();
|
||||
if value.is_empty() || value.len() > 64 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_alias_length_invalid",
|
||||
"wallet alias length must be between 1 and 64 bytes",
|
||||
));
|
||||
}
|
||||
if !value.bytes().enumerate().all(|(index, byte)| {
|
||||
return byte.is_ascii_alphanumeric() || (index > 0 && (byte == b'_' || byte == b'-'));
|
||||
}) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_alias_invalid",
|
||||
"wallet alias must start with an ASCII letter or digit and contain only ASCII letters, digits, '_' or '-'",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated alias text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-secret wallet policy used by callers before requesting a signature.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WalletPolicy {
|
||||
/// Whether signing is enabled for this wallet.
|
||||
pub signing_enabled: bool,
|
||||
/// Optional lamport spend limit enforced by the execution layer.
|
||||
pub lamport_spend_limit: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
/// Non-secret description of a loaded wallet.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WalletSummary {
|
||||
/// Validated local alias.
|
||||
pub alias: crate::WalletAlias,
|
||||
/// Base58 Solana public key.
|
||||
pub public_key: std::string::String,
|
||||
/// Persistent keypair path when the wallet was loaded from local storage.
|
||||
pub storage_path: std::option::Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Solana keypair kept private inside the wallet boundary.
|
||||
pub struct TemporaryWallet {
|
||||
alias: crate::WalletAlias,
|
||||
keypair: solana_keypair::Keypair,
|
||||
storage_path: std::option::Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::TemporaryWallet {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("TemporaryWallet")
|
||||
.field("alias", &self.alias)
|
||||
.field("public_key", &self.public_key())
|
||||
.field("storage_path", &self.storage_path)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::TemporaryWallet {
|
||||
/// Generates a non-persistent wallet in memory.
|
||||
pub fn generate(alias: crate::WalletAlias) -> Self {
|
||||
return Self {
|
||||
alias,
|
||||
keypair: solana_keypair::Keypair::new(),
|
||||
storage_path: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns a non-secret summary suitable for logs, CLI output or application adapters.
|
||||
pub fn summary(&self) -> crate::WalletSummary {
|
||||
return crate::WalletSummary {
|
||||
alias: self.alias.clone(),
|
||||
public_key: self.public_key(),
|
||||
storage_path: self.storage_path.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the wallet public key in base58 form.
|
||||
pub fn public_key(&self) -> std::string::String {
|
||||
return self.keypair.pubkey().to_string();
|
||||
}
|
||||
|
||||
/// Returns the signer interface without exposing keypair bytes.
|
||||
pub fn as_signer(&self) -> &dyn solana_signer::Signer {
|
||||
return &self.keypair;
|
||||
}
|
||||
|
||||
/// Returns the signer interface with the `Sync` auto-trait preserved for `Send` async futures.
|
||||
pub fn as_sync_signer(&self) -> &(dyn solana_signer::Signer + std::marker::Sync) {
|
||||
return &self.keypair;
|
||||
}
|
||||
|
||||
/// Signs arbitrary message bytes and returns the base58 signature.
|
||||
pub fn sign_message(&self, message: &[u8]) -> ks_core::Result<std::string::String> {
|
||||
let signature = match self.keypair.try_sign_message(message) {
|
||||
std::result::Result::Ok(signature) => signature,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_message_sign_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "sign_message",
|
||||
wallet_alias = self.alias.as_str(),
|
||||
public_key = %self.keypair.pubkey(),
|
||||
message_length = message.len(),
|
||||
"signed message with local wallet"
|
||||
);
|
||||
return std::result::Result::Ok(signature.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-backed store for development and integration-test wallets.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TemporaryWalletStore {
|
||||
directory: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl crate::TemporaryWalletStore {
|
||||
/// Creates a wallet store rooted at the supplied directory.
|
||||
pub fn new(directory: impl std::convert::Into<std::path::PathBuf>) -> ks_core::Result<Self> {
|
||||
let directory = directory.into();
|
||||
if directory.as_os_str().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_empty",
|
||||
"wallet directory must not be empty",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self { directory });
|
||||
}
|
||||
|
||||
/// Returns the configured wallet directory.
|
||||
pub fn directory(&self) -> &std::path::Path {
|
||||
return self.directory.as_path();
|
||||
}
|
||||
|
||||
/// Returns the deterministic JSON path for an alias.
|
||||
pub fn wallet_path(&self, alias: &crate::WalletAlias) -> std::path::PathBuf {
|
||||
return self.directory.join(format!("{}.json", alias.as_str()));
|
||||
}
|
||||
|
||||
/// Returns whether a persistent wallet exists for an alias.
|
||||
pub async fn exists(&self, alias: &crate::WalletAlias) -> ks_core::Result<bool> {
|
||||
let path = self.wallet_path(alias);
|
||||
return match tokio::fs::try_exists(&path).await {
|
||||
std::result::Result::Ok(exists) => std::result::Result::Ok(exists),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_exists_check_failed",
|
||||
format!("{}: {error}", path.display()),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates and persists a new wallet without overwriting an existing file.
|
||||
pub async fn create(
|
||||
&self,
|
||||
alias: crate::WalletAlias,
|
||||
) -> ks_core::Result<crate::TemporaryWallet> {
|
||||
match prepare_wallet_directory(&self.directory).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let path = self.wallet_path(&alias);
|
||||
let keypair = solana_keypair::Keypair::new();
|
||||
match write_new_keypair(path.clone(), &keypair).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "create_temporary_wallet",
|
||||
wallet_alias = alias.as_str(),
|
||||
public_key = %keypair.pubkey(),
|
||||
wallet_path = %path.display(),
|
||||
"created persistent temporary wallet"
|
||||
);
|
||||
return std::result::Result::Ok(crate::TemporaryWallet {
|
||||
alias,
|
||||
keypair,
|
||||
storage_path: std::option::Option::Some(path),
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads and validates an existing persistent wallet.
|
||||
pub async fn load(&self, alias: crate::WalletAlias) -> ks_core::Result<crate::TemporaryWallet> {
|
||||
let path = self.wallet_path(&alias);
|
||||
let keypair = match read_keypair(&path).await {
|
||||
std::result::Result::Ok(keypair) => keypair,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "load_temporary_wallet",
|
||||
wallet_alias = alias.as_str(),
|
||||
public_key = %keypair.pubkey(),
|
||||
wallet_path = %path.display(),
|
||||
"loaded persistent temporary wallet"
|
||||
);
|
||||
return std::result::Result::Ok(crate::TemporaryWallet {
|
||||
alias,
|
||||
keypair,
|
||||
storage_path: std::option::Option::Some(path),
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads an existing wallet or creates it when absent.
|
||||
pub async fn load_or_create(
|
||||
&self,
|
||||
alias: crate::WalletAlias,
|
||||
) -> ks_core::Result<crate::TemporaryWallet> {
|
||||
let exists = match self.exists(&alias).await {
|
||||
std::result::Result::Ok(exists) => exists,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if exists {
|
||||
return self.load(alias).await;
|
||||
}
|
||||
let create_result = self.create(alias.clone()).await;
|
||||
return match create_result {
|
||||
std::result::Result::Ok(wallet) => std::result::Result::Ok(wallet),
|
||||
std::result::Result::Err(error) if error.code() == "wallet_file_already_exists" => {
|
||||
return self.load(alias).await;
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
||||
match tokio::fs::create_dir_all(directory).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_create_failed",
|
||||
format!("{}: {error}", directory.display()),
|
||||
));
|
||||
},
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let permissions = std::fs::Permissions::from_mode(0o700);
|
||||
match tokio::fs::set_permissions(directory, permissions).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_permissions_failed",
|
||||
format!("{}: {error}", directory.display()),
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn write_new_keypair(
|
||||
path: std::path::PathBuf,
|
||||
keypair: &solana_keypair::Keypair,
|
||||
) -> ks_core::Result<()> {
|
||||
let mut keypair_bytes = keypair.to_bytes();
|
||||
let mut encoded = match serde_json::to_vec(keypair_bytes.as_slice()) {
|
||||
std::result::Result::Ok(encoded) => encoded,
|
||||
std::result::Result::Err(error) => {
|
||||
keypair_bytes.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_keypair_serialize_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
keypair_bytes.zeroize();
|
||||
let task_path = path.clone();
|
||||
let task_result = tokio::task::spawn_blocking(move || {
|
||||
use std::io::Write; // rust-rules: trait-import
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = match options.open(&task_path) {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
encoded.zeroize();
|
||||
let code = if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
"wallet_file_already_exists"
|
||||
} else {
|
||||
"wallet_file_create_failed"
|
||||
};
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
code,
|
||||
format!("{}: {error}", task_path.display()),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = file.write_all(encoded.as_slice()) {
|
||||
encoded.zeroize();
|
||||
let _ = std::fs::remove_file(&task_path);
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_write_failed",
|
||||
format!("{}: {error}", task_path.display()),
|
||||
));
|
||||
}
|
||||
encoded.zeroize();
|
||||
if let std::result::Result::Err(error) = file.sync_all() {
|
||||
let _ = std::fs::remove_file(&task_path);
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_sync_failed",
|
||||
format!("{}: {error}", task_path.display()),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
})
|
||||
.await;
|
||||
return match task_result {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_task_failed",
|
||||
format!("{}: {error}", path.display()),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
async fn read_keypair(path: &std::path::Path) -> ks_core::Result<solana_keypair::Keypair> {
|
||||
match validate_wallet_file_metadata(path).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let mut encoded = match tokio::fs::read(path).await {
|
||||
std::result::Result::Ok(encoded) => encoded,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_read_failed",
|
||||
format!("{}: {error}", path.display()),
|
||||
));
|
||||
},
|
||||
};
|
||||
let parsed_result = serde_json::from_slice::<std::vec::Vec<u8>>(encoded.as_slice());
|
||||
encoded.zeroize();
|
||||
let mut keypair_bytes = match parsed_result {
|
||||
std::result::Result::Ok(keypair_bytes) => keypair_bytes,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_keypair_json_invalid",
|
||||
format!("{}: {error}", path.display()),
|
||||
));
|
||||
},
|
||||
};
|
||||
if keypair_bytes.len() != crate::SOLANA_KEYPAIR_LENGTH {
|
||||
let length = keypair_bytes.len();
|
||||
keypair_bytes.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_keypair_length_invalid",
|
||||
format!(
|
||||
"{} contains {length} bytes instead of {}",
|
||||
path.display(),
|
||||
crate::SOLANA_KEYPAIR_LENGTH
|
||||
),
|
||||
));
|
||||
}
|
||||
let keypair_result = solana_keypair::Keypair::try_from(keypair_bytes.as_slice());
|
||||
keypair_bytes.zeroize();
|
||||
return match keypair_result {
|
||||
std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_keypair_invalid",
|
||||
format!("{}: {error}", path.display()),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
async fn validate_wallet_file_metadata(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let metadata = match tokio::fs::symlink_metadata(path).await {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_metadata_failed",
|
||||
format!("{}: {error}", path.display()),
|
||||
));
|
||||
},
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_type_invalid",
|
||||
format!("{} must be a regular file and not a symlink", path.display()),
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_permissions_too_open",
|
||||
format!("{} has mode {mode:o}; expected no group or other access", path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn wallet_alias_rejects_path_traversal_and_invalid_characters() {
|
||||
for value in ["", ".hidden", "../wallet", "wallet/name", "wallet name"] {
|
||||
assert!(crate::WalletAlias::parse(value).is_err());
|
||||
}
|
||||
assert!(crate::WalletAlias::parse("devnet-operator_01").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_wallet_signs_without_exposing_secret_bytes() {
|
||||
let alias = crate::WalletAlias::parse("ephemeral")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
let wallet = crate::TemporaryWallet::generate(alias);
|
||||
let signature = wallet
|
||||
.sign_message(b"khadhroony-wallet-test")
|
||||
.unwrap_or_else(|error| panic!("unexpected signing error: {error}"));
|
||||
assert!(!signature.is_empty());
|
||||
assert_eq!(wallet.public_key(), wallet.as_signer().pubkey().to_string());
|
||||
assert_eq!(wallet.public_key(), wallet.as_sync_signer().pubkey().to_string());
|
||||
assert!(wallet.summary().storage_path.is_none());
|
||||
assert!(!format!("{wallet:?}").contains("secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_wallet_roundtrips_and_preserves_public_key() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let store = crate::TemporaryWalletStore::new(directory.path().join("wallets"))
|
||||
.unwrap_or_else(|error| panic!("unexpected store error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("integration-wallet")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
let created = store
|
||||
.create(alias.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("unexpected create error: {error}"));
|
||||
let loaded = store
|
||||
.load(alias.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("unexpected load error: {error}"));
|
||||
assert_eq!(created.public_key(), loaded.public_key());
|
||||
assert_eq!(
|
||||
created.summary().storage_path,
|
||||
std::option::Option::Some(store.wallet_path(&alias))
|
||||
);
|
||||
let encoded = std::fs::read(store.wallet_path(&alias))
|
||||
.unwrap_or_else(|error| panic!("wallet file must be readable: {error}"));
|
||||
let keypair_bytes = serde_json::from_slice::<std::vec::Vec<u8>>(encoded.as_slice())
|
||||
.unwrap_or_else(|error| panic!("wallet file must be standard JSON: {error}"));
|
||||
assert_eq!(keypair_bytes.len(), crate::SOLANA_KEYPAIR_LENGTH);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_wallet_is_never_overwritten() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let store = crate::TemporaryWalletStore::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected store error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("existing")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
let first = store
|
||||
.create(alias.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("unexpected create error: {error}"));
|
||||
let second = store.create(alias.clone()).await;
|
||||
assert!(second.is_err());
|
||||
let loaded = store
|
||||
.load_or_create(alias)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("unexpected load-or-create error: {error}"));
|
||||
assert_eq!(first.public_key(), loaded.public_key());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_wallet_file_is_rejected() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let store = crate::TemporaryWalletStore::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected store error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("corrupted")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
store
|
||||
.create(alias.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("fixture wallet must be created: {error}"));
|
||||
std::fs::write(store.wallet_path(&alias), b"[1,2,3]")
|
||||
.unwrap_or_else(|error| panic!("fixture must be writable: {error}"));
|
||||
let result = store.load(alias).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("corrupted wallet must return an error"))
|
||||
.code(),
|
||||
"wallet_keypair_length_invalid"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn persistent_wallet_uses_private_unix_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let store = crate::TemporaryWalletStore::new(directory.path().join("private-wallets"))
|
||||
.unwrap_or_else(|error| panic!("unexpected store error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("permissions")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
store
|
||||
.create(alias.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("unexpected create error: {error}"));
|
||||
let directory_mode = std::fs::metadata(store.directory())
|
||||
.unwrap_or_else(|error| panic!("directory metadata must exist: {error}"))
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
let file_mode = std::fs::metadata(store.wallet_path(&alias))
|
||||
.unwrap_or_else(|error| panic!("file metadata must exist: {error}"))
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(directory_mode, 0o700);
|
||||
assert_eq!(file_mode, 0o600);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user