Skip to content

Commit 6a96026

Browse files
authored
Merge pull request #861 from gospeltout/Version-and-tune-password-based-encryption-parameters
feat(wallet): version and tune per-wallet KDF encryption parameters
2 parents 477b464 + 061bf6d commit 6a96026

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
@@ -204,6 +204,45 @@ export STARFORGE_CONFIG_DIR=~/.starforge-dev
204204
### Secret Redaction & Security Logging
205205
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.
206206

207+
### Password-Based Encryption & KDF Parameter Tuning
208+
209+
StarForge encrypts Stellar secret keys at rest using **Argon2id** key derivation and **AES-256-GCM** authenticated encryption.
210+
211+
#### KDF Versioning & Schema Formats
212+
213+
- **Version 1 (`KDF_VERSION_1 = 1`)**: Argon2id + AES-256-GCM.
214+
- **Bundle Formats**:
215+
- Legacy 3-part: `salt:nonce:ciphertext` (library defaults: 32,768 KiB memory, 3 iterations, 1 parallelism thread).
216+
- 5-part: `salt:nonce:ciphertext:mem:iterations` (custom memory cost and iteration count).
217+
- 6-part: `salt:nonce:ciphertext:mem:iterations:parallelism` (custom memory, iterations, and parallelism).
218+
- Versioned 7-part: `v1:salt:nonce:ciphertext:mem:iterations:parallelism` (explicit version prefixing for modern tuned bundles).
219+
220+
#### Parameter Bounds & Safety Constraints
221+
222+
- **Memory Cost (`mem`)**: Min 8,192 KiB (8 MiB), Max 2,097,152 KiB (2 GiB).
223+
- **Iterations (`iterations`)**: Min 1, Max 100.
224+
- **Parallelism (`parallelism`)**: Min 1, Max 64 threads.
225+
226+
#### Per-Wallet Metadata & Safe Upgrades
227+
228+
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:
229+
230+
```bash
231+
# Tune KDF parameters for a specific wallet
232+
starforge wallet tune-kdf alice --mem 65536 --iterations 4 --parallelism 2
233+
234+
# Upgrade wallet KDF to global configuration settings
235+
starforge wallet tune-kdf alice --use-global
236+
```
237+
238+
The upgrade procedure enforces zero-data-loss safety:
239+
1. Validates existing password against current bundle before making any changes.
240+
2. Validates new KDF parameters against security bounds.
241+
3. Re-encrypts secret key with new parameters.
242+
4. Performs a verification decryption round-trip on the new bundle before persisting changes to disk and database.
243+
5. If any validation or decryption step fails, the original encrypted secret and metadata remain completely unchanged.
244+
245+
207246
### Development Workflow
208247

209248
```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);
@@ -1698,6 +1810,7 @@ fn import_from_hardware(
16981810
network,
16991811
created_at: Utc::now().to_rfc3339(),
17001812
funded: false,
1813+
kdf_options: None,
17011814
rotation_history: vec![],
17021815
});
17031816
config::save(&updated_cfg)?;
@@ -1747,13 +1860,19 @@ fn import_from_mnemonic(
17471860
secret_key
17481861
};
17491862

1863+
let kdf = if encrypt {
1864+
kdf_options(None, None, None, cfg.wallet_encryption.as_ref())
1865+
} else {
1866+
None
1867+
};
17501868
cfg.wallets.push(config::WalletEntry {
17511869
name: name.clone(),
17521870
public_key,
17531871
secret_key: Some(secret_to_store),
17541872
network: network.clone(),
17551873
created_at: Utc::now().to_rfc3339(),
17561874
funded: false,
1875+
kdf_options: kdf,
17571876
rotation_history: Vec::new(),
17581877
});
17591878

@@ -1805,13 +1924,19 @@ fn import_from_secret_key(
18051924
secret_key
18061925
};
18071926

1927+
let kdf = if encrypt {
1928+
kdf_options(None, None, None, cfg.wallet_encryption.as_ref())
1929+
} else {
1930+
None
1931+
};
18081932
cfg.wallets.push(config::WalletEntry {
18091933
name: name.clone(),
18101934
public_key,
18111935
secret_key: Some(secret_to_store),
18121936
network,
18131937
created_at: Utc::now().to_rfc3339(),
18141938
funded: false,
1939+
kdf_options: kdf,
18151940
rotation_history: Vec::new(),
18161941
});
18171942

@@ -1863,13 +1988,23 @@ fn import_wallets(file: PathBuf) -> Result<()> {
18631988

18641989
let imported = backup.wallets.len();
18651990
for wallet in backup.wallets {
1991+
let kdf_options = wallet
1992+
.secret_key
1993+
.as_ref()
1994+
.and_then(|s| crypto::extract_kdf_metadata(s).ok())
1995+
.map(|m| crypto::KdfOptions {
1996+
mem: Some(m.mem),
1997+
iterations: Some(m.iterations),
1998+
parallelism: Some(m.parallelism),
1999+
});
18662000
cfg.wallets.push(config::WalletEntry {
18672001
name: wallet.name,
18682002
public_key: wallet.public_key,
18692003
secret_key: wallet.secret_key,
18702004
network: wallet.network,
18712005
created_at: wallet.created_at,
18722006
funded: wallet.funded,
2007+
kdf_options,
18732008
rotation_history: Vec::new(),
18742009
});
18752010
}

src/utils/config.rs

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

125151
// If 5 or 6-part bundle, validate KDF parameters are valid u32
152+
let mut mem = None;
153+
let mut iterations = None;
154+
let mut parallelism = None;
126155
if parts.len() >= 5 {
127-
parts[3]
128-
.parse::<u32>()
129-
.map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?;
130-
parts[4]
131-
.parse::<u32>()
132-
.map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?;
156+
mem = Some(
157+
parts[3]
158+
.parse::<u32>()
159+
.map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?,
160+
);
161+
iterations = Some(
162+
parts[4]
163+
.parse::<u32>()
164+
.map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?,
165+
);
133166
}
134167
if parts.len() == 6 {
135-
parts[5].parse::<u32>().map_err(|_| {
136-
anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32")
137-
})?;
168+
parallelism = Some(
169+
parts[5].parse::<u32>().map_err(|_| {
170+
anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32")
171+
})?,
172+
);
138173
}
174+
crypto::validate_kdf_params(mem, iterations, parallelism)?;
139175

140176
return Ok(());
141177
}
@@ -658,10 +694,55 @@ pub struct WalletEntry {
658694
pub network: String,
659695
pub created_at: String,
660696
pub funded: bool,
697+
#[serde(default, skip_serializing_if = "Option::is_none")]
698+
pub kdf_options: Option<crypto::KdfOptions>,
661699
#[serde(default)]
662700
pub rotation_history: Vec<WalletRotationRecord>,
663701
}
664702

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

0 commit comments

Comments
 (0)