Skip to content

Commit 061bf6d

Browse files
committed
feat(wallet): version and tune per-wallet KDF encryption parameters
1 parent 5f189fa commit 061bf6d

8 files changed

Lines changed: 737 additions & 39 deletions

File tree

DEVELOPER_GUIDE.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,45 @@ export STARFORGE_CONFIG_DIR=~/.starforge-dev
179179
### Secret Redaction & Security Logging
180180
StarForge enforces centralized secret redaction via `crate::utils::redaction::redact_secrets`. Tracing output streams (`RUST_LOG`) and CLI error output streams automatically sanitize Stellar secret keys (`S...`), hex private keys, BIP-39 mnemonic seed phrases, auth tokens (`Bearer`, `ghp_`, `sk-`), signed XDR transaction payloads, and embedded URL credentials before output. Existing helper functions (`redact_public_key`, `redact_secret_value`, `redact_signed_xdr`) delegate to this centralized engine.
181181

182+
### Password-Based Encryption & KDF Parameter Tuning
183+
184+
StarForge encrypts Stellar secret keys at rest using **Argon2id** key derivation and **AES-256-GCM** authenticated encryption.
185+
186+
#### KDF Versioning & Schema Formats
187+
188+
- **Version 1 (`KDF_VERSION_1 = 1`)**: Argon2id + AES-256-GCM.
189+
- **Bundle Formats**:
190+
- Legacy 3-part: `salt:nonce:ciphertext` (library defaults: 32,768 KiB memory, 3 iterations, 1 parallelism thread).
191+
- 5-part: `salt:nonce:ciphertext:mem:iterations` (custom memory cost and iteration count).
192+
- 6-part: `salt:nonce:ciphertext:mem:iterations:parallelism` (custom memory, iterations, and parallelism).
193+
- Versioned 7-part: `v1:salt:nonce:ciphertext:mem:iterations:parallelism` (explicit version prefixing for modern tuned bundles).
194+
195+
#### Parameter Bounds & Safety Constraints
196+
197+
- **Memory Cost (`mem`)**: Min 8,192 KiB (8 MiB), Max 2,097,152 KiB (2 GiB).
198+
- **Iterations (`iterations`)**: Min 1, Max 100.
199+
- **Parallelism (`parallelism`)**: Min 1, Max 64 threads.
200+
201+
#### Per-Wallet Metadata & Safe Upgrades
202+
203+
KDF parameters are stored per wallet (`WalletEntry.kdf_options` and metadata embedded in `secret_key`). Wallet encryption parameters can be tuned or upgraded safely without data loss using:
204+
205+
```bash
206+
# Tune KDF parameters for a specific wallet
207+
starforge wallet tune-kdf alice --mem 65536 --iterations 4 --parallelism 2
208+
209+
# Upgrade wallet KDF to global configuration settings
210+
starforge wallet tune-kdf alice --use-global
211+
```
212+
213+
The upgrade procedure enforces zero-data-loss safety:
214+
1. Validates existing password against current bundle before making any changes.
215+
2. Validates new KDF parameters against security bounds.
216+
3. Re-encrypts secret key with new parameters.
217+
4. Performs a verification decryption round-trip on the new bundle before persisting changes to disk and database.
218+
5. If any validation or decryption step fails, the original encrypted secret and metadata remain completely unchanged.
219+
220+
182221
### Development Workflow
183222

184223
```bash

src/commands/wallet.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,23 @@ pub enum WalletCommands {
270270
#[arg(long, value_enum)]
271271
hardware: Option<hardware_wallet::HardwareWalletKind>,
272272
},
273+
/// Tune or upgrade KDF encryption parameters for a saved encrypted wallet
274+
TuneKdf {
275+
/// Wallet name to upgrade
276+
name: String,
277+
/// Argon2 memory cost in KiB (e.g. 65536)
278+
#[arg(long)]
279+
mem: Option<u32>,
280+
/// Argon2 iteration count (e.g. 4)
281+
#[arg(long)]
282+
iterations: Option<u32>,
283+
/// Argon2 parallelism factor (e.g. 2)
284+
#[arg(long)]
285+
parallelism: Option<u32>,
286+
/// Upgrade to global configuration KDF parameters
287+
#[arg(long, default_value = "false")]
288+
use_global: bool,
289+
},
273290
/// Derive all 10 Stellar addresses (m/44'/148'/0..9') from a BIP39 recovery phrase
274291
Derive,
275292
/// Multi-signature account management
@@ -450,10 +467,99 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> {
450467
hardware,
451468
} => sign_message(name, message, hardware),
452469
WalletCommands::Derive => derive_addresses(),
470+
WalletCommands::TuneKdf {
471+
name,
472+
mem,
473+
iterations,
474+
parallelism,
475+
use_global,
476+
} => tune_wallet_kdf(&name, mem, iterations, parallelism, use_global),
453477
WalletCommands::Multisig(cmd) => handle_multisig(cmd).await,
454478
}
455479
}
456480

481+
fn tune_wallet_kdf(
482+
name: &str,
483+
mem: Option<u32>,
484+
iterations: Option<u32>,
485+
parallelism: Option<u32>,
486+
use_global: bool,
487+
) -> Result<()> {
488+
p::header(&format!("Tune KDF Encryption Parameters: '{}'", name));
489+
490+
let cfg = config::load()?;
491+
let wallet = cfg
492+
.wallets
493+
.iter()
494+
.find(|w| w.name == name)
495+
.ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", name))?;
496+
497+
let secret_bundle = wallet
498+
.secret_key
499+
.as_ref()
500+
.ok_or_else(|| anyhow::anyhow!("Wallet '{}' has no secret key saved", name))?;
501+
502+
if !secret_bundle.contains(':') {
503+
anyhow::bail!(
504+
"Wallet '{}' is unencrypted. Run `wallet rotate --encrypt` to enable encryption first.",
505+
name
506+
);
507+
}
508+
509+
let current_meta = wallet
510+
.kdf_metadata()
511+
.ok_or_else(|| anyhow::anyhow!("Failed to parse current KDF metadata for '{}'", name))?;
512+
513+
p::kv("Current KDF Version", &current_meta.version.to_string());
514+
p::kv("Current Memory", &format!("{} KiB", current_meta.mem));
515+
p::kv("Current Iterations", &current_meta.iterations.to_string());
516+
p::kv("Current Parallelism", &current_meta.parallelism.to_string());
517+
518+
if !use_global && mem.is_none() && iterations.is_none() && parallelism.is_none() {
519+
anyhow::bail!(
520+
"Specify at least one parameter to update (--mem, --iterations, --parallelism) or use --use-global."
521+
);
522+
}
523+
524+
let global_default = cfg.wallet_encryption.as_ref();
525+
let target_options = if use_global {
526+
global_default.cloned().unwrap_or_default()
527+
} else {
528+
crypto::KdfOptions {
529+
mem: mem.or(Some(current_meta.mem)),
530+
iterations: iterations.or(Some(current_meta.iterations)),
531+
parallelism: parallelism.or(Some(current_meta.parallelism)),
532+
}
533+
};
534+
535+
target_options.validate()?;
536+
537+
let password = crypto::prompt_password("Enter wallet passphrase", false)?;
538+
539+
config::upgrade_wallet_kdf(name, &password, Some(target_options))?;
540+
541+
let updated_cfg = config::load()?;
542+
let updated_wallet = updated_cfg
543+
.wallets
544+
.iter()
545+
.find(|w| w.name == name)
546+
.ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found after upgrade", name))?;
547+
548+
if let Some(new_meta) = updated_wallet.kdf_metadata() {
549+
p::separator();
550+
p::success(&format!(
551+
"Successfully upgraded KDF parameters for wallet '{}'",
552+
name
553+
));
554+
p::kv("Upgraded KDF Version", &new_meta.version.to_string());
555+
p::kv("Upgraded Memory", &format!("{} KiB", new_meta.mem));
556+
p::kv("Upgraded Iterations", &new_meta.iterations.to_string());
557+
p::kv("Upgraded Parallelism", &new_meta.parallelism.to_string());
558+
}
559+
560+
Ok(())
561+
}
562+
457563
fn parse_duration(input: &str) -> Result<std::time::Duration> {
458564
let trimmed = input.trim().to_lowercase();
459565
if trimmed.ends_with("ms") {
@@ -694,13 +800,19 @@ async fn create(
694800
println!();
695801

696802
p::step(2, steps, "Saving to ~/.starforge/config.toml…");
803+
let kdf = if encrypt {
804+
kdf_options(mem, iterations, parallelism, cfg.wallet_encryption.as_ref())
805+
} else {
806+
None
807+
};
697808
let wallet = config::WalletEntry {
698809
name: name.clone(),
699810
public_key: public_key.clone(),
700811
secret_key: Some(secret_to_store),
701812
network: network.clone(),
702813
created_at: Utc::now().to_rfc3339(),
703814
funded: false,
815+
kdf_options: kdf,
704816
rotation_history: Vec::new(),
705817
};
706818
cfg.wallets.push(wallet);
@@ -1670,6 +1782,7 @@ fn import_from_hardware(
16701782
network,
16711783
created_at: Utc::now().to_rfc3339(),
16721784
funded: false,
1785+
kdf_options: None,
16731786
rotation_history: vec![],
16741787
});
16751788
config::save(&updated_cfg)?;
@@ -1719,13 +1832,19 @@ fn import_from_mnemonic(
17191832
secret_key
17201833
};
17211834

1835+
let kdf = if encrypt {
1836+
kdf_options(None, None, None, cfg.wallet_encryption.as_ref())
1837+
} else {
1838+
None
1839+
};
17221840
cfg.wallets.push(config::WalletEntry {
17231841
name: name.clone(),
17241842
public_key,
17251843
secret_key: Some(secret_to_store),
17261844
network: network.clone(),
17271845
created_at: Utc::now().to_rfc3339(),
17281846
funded: false,
1847+
kdf_options: kdf,
17291848
rotation_history: Vec::new(),
17301849
});
17311850

@@ -1777,13 +1896,19 @@ fn import_from_secret_key(
17771896
secret_key
17781897
};
17791898

1899+
let kdf = if encrypt {
1900+
kdf_options(None, None, None, cfg.wallet_encryption.as_ref())
1901+
} else {
1902+
None
1903+
};
17801904
cfg.wallets.push(config::WalletEntry {
17811905
name: name.clone(),
17821906
public_key,
17831907
secret_key: Some(secret_to_store),
17841908
network,
17851909
created_at: Utc::now().to_rfc3339(),
17861910
funded: false,
1911+
kdf_options: kdf,
17871912
rotation_history: Vec::new(),
17881913
});
17891914

@@ -1835,13 +1960,23 @@ fn import_wallets(file: PathBuf) -> Result<()> {
18351960

18361961
let imported = backup.wallets.len();
18371962
for wallet in backup.wallets {
1963+
let kdf_options = wallet
1964+
.secret_key
1965+
.as_ref()
1966+
.and_then(|s| crypto::extract_kdf_metadata(s).ok())
1967+
.map(|m| crypto::KdfOptions {
1968+
mem: Some(m.mem),
1969+
iterations: Some(m.iterations),
1970+
parallelism: Some(m.parallelism),
1971+
});
18381972
cfg.wallets.push(config::WalletEntry {
18391973
name: wallet.name,
18401974
public_key: wallet.public_key,
18411975
secret_key: wallet.secret_key,
18421976
network: wallet.network,
18431977
created_at: wallet.created_at,
18441978
funded: wallet.funded,
1979+
kdf_options,
18451980
rotation_history: Vec::new(),
18461981
});
18471982
}

src/utils/config.rs

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,39 @@ pub fn validate_network(network: &str) -> Result<()> {
103103
pub fn validate_secret_key(secret: &str) -> Result<()> {
104104
if secret.contains(':') {
105105
let parts: Vec<&str> = secret.split(':').collect();
106+
107+
if parts[0] == "v1" {
108+
if parts.len() != 7 {
109+
anyhow::bail!(
110+
"Invalid v1 encrypted secret bundle format: expected 7 parts (v1:salt:nonce:ciphertext:mem:iterations:parallelism), got {}",
111+
parts.len()
112+
);
113+
}
114+
for part in parts.iter().skip(1).take(3) {
115+
BASE64
116+
.decode(part)
117+
.map_err(|_| anyhow::anyhow!("Invalid base64 in encrypted secret bundle"))?;
118+
}
119+
let mem: u32 = parts[4]
120+
.parse()
121+
.map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?;
122+
let iterations: u32 = parts[5]
123+
.parse()
124+
.map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?;
125+
let parallelism: u32 = parts[6]
126+
.parse()
127+
.map_err(|_| anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32"))?;
128+
crypto::validate_kdf_params(Some(mem), Some(iterations), Some(parallelism))?;
129+
return Ok(());
130+
}
131+
106132
// Accept:
107133
// - 3-part (legacy: salt:nonce:ciphertext)
108134
// - 5-part (KDF without p_cost: salt:nonce:ciphertext:mem:iterations)
109135
// - 6-part (KDF with p_cost: salt:nonce:ciphertext:mem:iterations:parallelism)
110136
if parts.len() != 3 && parts.len() != 5 && parts.len() != 6 {
111137
anyhow::bail!(
112-
"Invalid encrypted secret bundle format: expected 3, 5, or 6 parts, got {}",
138+
"Invalid encrypted secret bundle format: expected 3, 5, 6, or 7 parts, got {}",
113139
parts.len()
114140
);
115141
}
@@ -122,19 +148,29 @@ pub fn validate_secret_key(secret: &str) -> Result<()> {
122148
}
123149

124150
// If 5 or 6-part bundle, validate KDF parameters are valid u32
151+
let mut mem = None;
152+
let mut iterations = None;
153+
let mut parallelism = None;
125154
if parts.len() >= 5 {
126-
parts[3]
127-
.parse::<u32>()
128-
.map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?;
129-
parts[4]
130-
.parse::<u32>()
131-
.map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?;
155+
mem = Some(
156+
parts[3]
157+
.parse::<u32>()
158+
.map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?,
159+
);
160+
iterations = Some(
161+
parts[4]
162+
.parse::<u32>()
163+
.map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?,
164+
);
132165
}
133166
if parts.len() == 6 {
134-
parts[5].parse::<u32>().map_err(|_| {
135-
anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32")
136-
})?;
167+
parallelism = Some(
168+
parts[5].parse::<u32>().map_err(|_| {
169+
anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32")
170+
})?,
171+
);
137172
}
173+
crypto::validate_kdf_params(mem, iterations, parallelism)?;
138174

139175
return Ok(());
140176
}
@@ -657,10 +693,55 @@ pub struct WalletEntry {
657693
pub network: String,
658694
pub created_at: String,
659695
pub funded: bool,
696+
#[serde(default, skip_serializing_if = "Option::is_none")]
697+
pub kdf_options: Option<crypto::KdfOptions>,
660698
#[serde(default)]
661699
pub rotation_history: Vec<WalletRotationRecord>,
662700
}
663701

702+
impl WalletEntry {
703+
/// Get explicit or extracted KDF metadata for this wallet entry if encrypted.
704+
pub fn kdf_metadata(&self) -> Option<crypto::KdfMetadata> {
705+
let secret = self.secret_key.as_ref()?;
706+
crypto::extract_kdf_metadata(secret).ok()
707+
}
708+
}
709+
710+
/// Upgrade or tune KDF parameters for a stored wallet.
711+
pub fn upgrade_wallet_kdf(
712+
wallet_name: &str,
713+
password: &str,
714+
new_kdf: Option<crypto::KdfOptions>,
715+
) -> Result<()> {
716+
let mut cfg = load()?;
717+
let wallet = cfg
718+
.wallets
719+
.iter_mut()
720+
.find(|w| w.name == wallet_name)
721+
.ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", wallet_name))?;
722+
723+
let secret_bundle = wallet
724+
.secret_key
725+
.as_ref()
726+
.ok_or_else(|| anyhow::anyhow!("Wallet '{}' has no secret key saved", wallet_name))?;
727+
728+
if !secret_bundle.contains(':') {
729+
anyhow::bail!(
730+
"Wallet '{}' secret key is not encrypted. KDF parameters can only be tuned for encrypted wallets.",
731+
wallet_name
732+
);
733+
}
734+
735+
let upgraded_bundle =
736+
crypto::upgrade_wallet_kdf_secret(password, secret_bundle, new_kdf.as_ref())?;
737+
738+
wallet.secret_key = Some(upgraded_bundle);
739+
wallet.kdf_options = new_kdf;
740+
741+
save(&cfg)?;
742+
Ok(())
743+
}
744+
664745
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
665746
pub struct WalletRotationRecord {
666747
pub rotated_at: String,

0 commit comments

Comments
 (0)