This commit is contained in:
2026-07-23 16:37:12 +02:00
parent 99c345f2f2
commit 0da75c1311
2159 changed files with 230833 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# file: kb_wallet/Cargo.toml
# version: 3
[package]
name = "kb_wallet"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kb_core = { path = "../kb_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

View File

@@ -0,0 +1,25 @@
<!-- file: kb_wallet/README.md -->
<!-- version: 2 -->
# kb_wallet
Ce crate isole les secrets, le stockage local des keypairs et la signature. Les décodeurs, matérialisateurs, exécuteurs et interfaces utilisateur ne doivent jamais lire directement les octets privés.
## Wallet temporaire persistant
`TemporaryWalletStore` fournit une première implémentation destinée aux tests d'intégration, aux exécutables de développement et aux campagnes devnet :
- alias validé et transformé en chemin déterministe `<wallet_dir>/<alias>.json` ;
- création sans écrasement d'un fichier existant ;
- chargement et validation stricte des 64 octets du keypair Solana ;
- format JSON standard sous forme de tableau d'octets ;
- permissions Unix `0700` pour le répertoire et `0600` pour le fichier ;
- copies sérialisées des secrets effacées de la mémoire après utilisation ;
- exposition limitée au public key, au résumé non secret et à l'interface `Signer` ;
- aucune exportation TS-rs du keypair ou de ses octets.
Le terme « temporaire » décrit son usage de laboratoire et non nécessairement sa durée de vie : lorsque la persistance est activée dans la configuration, le même keypair est rechargé entre deux démarrages afin de pouvoir recevoir un airdrop, signer plusieurs transactions et valider leur historique.
## Limites
Cette tranche n'ajoute ni chiffrement par mot de passe, ni coffre système, ni hardware wallet. Ces backends devront implémenter la même frontière de signature sans modifier les exécuteurs. Un wallet temporaire persistant ne doit pas être sélectionné pour un envoi mainnet dans les profils fournis.

View File

@@ -0,0 +1,9 @@
// file: kb_wallet/src/constants.rs
// version: 1
//! Local constants for the `kb_wallet` crate.
/// Canonical tracing target for this crate.
pub(crate) const TRACING_TARGET: &str = "kb_wallet";
/// Number of bytes stored by the standard Solana keypair JSON format.
pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64;

View File

@@ -0,0 +1,26 @@
// file: kb_wallet/src/lib.rs
// version: 6
//! 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 crate::constants::SOLANA_KEYPAIR_LENGTH;
/// Canonical tracing target for this crate.
pub(crate) use crate::constants::TRACING_TARGET;
/// Solana keypair kept private inside the wallet boundary.
pub use crate::wallet::TemporaryWallet;
/// Filesystem-backed store for development and integration-test wallets.
pub use crate::wallet::TemporaryWalletStore;
/// Validated non-secret wallet alias.
pub use crate::wallet::WalletAlias;
/// Non-secret wallet policy.
pub use crate::wallet::WalletPolicy;
/// Non-secret wallet description.
pub use crate::wallet::WalletSummary;

View File

@@ -0,0 +1,545 @@
// file: kb_wallet/src/wallet.rs
// version: 7
//! 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>) -> kb_core::Result<Self> {
let value = value.into();
if value.is_empty() || value.len() > 64 {
return std::result::Result::Err(kb_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(kb_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;
}
/// Signs arbitrary message bytes and returns the base58 signature.
pub fn sign_message(&self, message: &[u8]) -> kb_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(kb_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>) -> kb_core::Result<Self> {
let directory = directory.into();
if directory.as_os_str().is_empty() {
return std::result::Result::Err(kb_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) -> kb_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(kb_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,
) -> kb_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) -> kb_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,
) -> kb_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) -> kb_core::Result<()> {
match tokio::fs::create_dir_all(directory).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_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(kb_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,
) -> kb_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(kb_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(kb_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(kb_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(kb_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(kb_core::Error::new(
"wallet_file_task_failed",
format!("{}: {error}", path.display()),
)),
};
}
async fn read_keypair(path: &std::path::Path) -> kb_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(kb_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(kb_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(kb_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(kb_core::Error::new(
"wallet_keypair_invalid",
format!("{}: {error}", path.display()),
)),
};
}
async fn validate_wallet_file_metadata(path: &std::path::Path) -> kb_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(kb_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(kb_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(kb_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!(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);
}
}