Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .codex
Empty file.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mostro.db*
mostro.log
lnurl-test-server/target
vendor

# IDE's
.idea
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,8 @@ payment_retries_interval = 60 # seconds between retries
#### Nostr Configuration
```toml
[nostr]
# Your Mostro daemon's private key (nsec format)
nsec_privkey = 'nsec1...'
# Path to a file containing your Mostro daemon's private key (nsec format)
nsec_privkey_file = '/home/user/.mostro/nostr-key.nsec'

# Relays to connect to
relays = [
Expand All @@ -509,6 +509,10 @@ relays = [
rana --vanity mostro
```

Save the generated `nsec` in the file referenced by `nsec_privkey_file`.
Mostro expects that file to contain only the `nsec` value.
Inline `nostr.nsec_privkey` is no longer supported and startup will fail until you move the key into a file.

**Important**: Never reuse keys between Mostro instances. Each daemon needs a unique identity.

---
Expand Down
6 changes: 4 additions & 2 deletions docs/STARTUP_AND_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ Configuration is loaded from `~/.mostro/settings.toml` (template: `settings.tpl.
- Example (absolute path; use a real path — **do not** use `~`; SQLx does not expand tilde): `"sqlite:///home/youruser/.mostro/mostro.db"`
- Default: `"sqlite://mostro.db"`

**Nostr** (`src/config/types.rs:47-54`):
- `nsec_privkey` (String): Mostro's Nostr private key in nsec format
**Nostr** (`src/config/types.rs`):
- `nsec_privkey_file` (String): Path to a file containing Mostro's Nostr private key in nsec format
- The file should contain only the `nsec` value
- Inline `nsec_privkey` is no longer supported and causes startup validation to fail
- `relays` (Vec<String>): List of Nostr relay URLs for event broadcasting
- Default: `['ws://localhost:7000']`
- Note: At least one relay required
Expand Down
2 changes: 1 addition & 1 deletion settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ payment_attempts = 3
payment_retries_interval = 60

[nostr]
nsec_privkey = 'nsec1...'
nsec_privkey_file = '/home/user/nostr-key.nsec'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the template path aligned with the runtime default location.

run_setup_wizard() now writes the key under the Mostro settings dir, and the docs/examples point to ~/.mostro/nostr-key.nsec. Leaving /home/user/nostr-key.nsec here makes the copied template inconsistent and easy to misconfigure.

Suggested tweak
-nsec_privkey_file = '/home/user/nostr-key.nsec'
+nsec_privkey_file = '/home/user/.mostro/nostr-key.nsec'

Based on learnings, copy from settings.tpl.toml to ~/.mostro/settings.toml for local runs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
nsec_privkey_file = '/home/user/nostr-key.nsec'
nsec_privkey_file = '/home/user/.mostro/nostr-key.nsec'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@settings.tpl.toml` at line 22, Update the template default path for the
private key so it matches the runtime/default location used by
run_setup_wizard(): replace the hardcoded '/home/user/nostr-key.nsec' value for
the nsec_privkey_file entry in settings.tpl.toml with the Mostro settings dir
path (~/.mostro/nostr-key.nsec) so the copied settings (when creating
~/.mostro/settings.toml) and documentation are consistent with the actual
runtime behavior.

relays = ['ws://localhost:7000']

[mostro]
Expand Down
34 changes: 27 additions & 7 deletions src/app/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl AppContext {

/// Mostro's Nostr signing keys.
///
/// Parsed once at startup from `settings.nostr.nsec_privkey`.
/// Parsed once at startup from `settings.nostr.nsec_privkey_file`.
/// Use this instead of `get_keys()` to avoid re-parsing on every call.
pub fn keys(&self) -> &Keys {
&self.keys
Expand All @@ -99,6 +99,7 @@ pub mod test_utils {
DatabaseSettings, ExpirationSettings, LightningSettings, MostroSettings, NostrSettings,
RpcSettings,
};
use std::sync::atomic::{AtomicU64, Ordering};

/// Test helper wrapper for inspecting the shared order-message queue.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -228,10 +229,17 @@ pub mod test_utils {
.order_msg_queue
.unwrap_or_else(|| Arc::new(RwLock::new(Vec::new())));

// Use provided keys or parse from settings
// Use provided keys or load from nsec_privkey_file
let keys = self.keys.unwrap_or_else(|| {
Keys::parse(&settings.nostr.nsec_privkey)
.expect("TestContextBuilder: invalid nsec_privkey in settings")
let nsec = std::fs::read_to_string(&settings.nostr.nsec_privkey_file)
.unwrap_or_else(|e| {
panic!(
"TestContextBuilder: failed to read nsec_privkey_file '{}': {}",
settings.nostr.nsec_privkey_file, e
)
});
Keys::parse(nsec.trim())
.expect("TestContextBuilder: invalid nsec in nsec_privkey_file")
});

AppContext::new(pool, nostr_client, settings, order_msg_queue, keys)
Expand All @@ -251,17 +259,29 @@ pub mod test_utils {
}
}

static TEST_KEY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Generate deterministic test settings with sensible defaults.
pub fn test_settings() -> Settings {
let nsec_key = "nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd";
let counter = TEST_KEY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
let nsec_dir = std::env::temp_dir().join(format!(
"mostro-test-{}-{}",
std::process::id(),
counter
));
std::fs::create_dir_all(&nsec_dir).expect("failed to create test nsec key directory");
let nsec_path = nsec_dir.join("nostr-key.nsec");
std::fs::write(&nsec_path, nsec_key).expect("failed to write test nsec key file");

Comment on lines +266 to +276
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid committing a valid-looking nsec test secret.

Line 266 embeds a real-looking Nostr private key, and secret scanners will keep flagging it even if it is only test data. Prefer generating a throwaway key at test setup time or loading a scanner-excluded fixture so this path stops producing false-positive secret incidents.

🧰 Tools
🪛 Betterleaks (1.1.1)

[high] 266-266: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/context.rs` around lines 266 - 276, The test currently embeds a
real-looking Nostr private key in the nsec_key variable which triggers secret
scanners; replace the hard-coded string by generating a throwaway/test key at
runtime (or load a scanner-excluded fixture) instead: in the test setup where
nsec_key is defined, call a local helper or library function to produce a
temporary/ephemeral nsec value and continue to use TEST_KEY_FILE_COUNTER,
nsec_dir and nsec_path as before so the rest of the test is unchanged; ensure
the generated key matches the expected nsec format used by the code under test
so writes to nsec_path succeed without exposing any real secret.

Settings {
database: DatabaseSettings {
url: "sqlite::memory:".to_string(),
},
nostr: NostrSettings {
// Valid test nsec from src/config/mod.rs tests
nsec_privkey: "nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd"
.to_string(),
nsec_privkey_file: nsec_path.to_string_lossy().to_string(),
relays: vec!["wss://relay.test".to_string()],
..Default::default()
},
mostro: MostroSettings::default(),
lightning: LightningSettings::default(),
Expand Down
6 changes: 3 additions & 3 deletions src/app/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,14 +678,14 @@ async fn create_order_event(

// If user has sent the order with his identity key means that he wants to be rate so we can just
// check if we have identity key in db - if present we have to send reputation tags otherwise no.
let mostro_pubkey = my_keys.public_key().to_hex();
let mostro_pubkey = my_keys.public_key();
let tags = match crate::db::is_user_present(pool, identity_pubkey.to_string()).await {
Ok(user) => order_to_tags(
new_order,
Some((user.total_rating, user.total_reviews, user.created_at)),
Some(&mostro_pubkey),
&mostro_pubkey,
)?,
Err(_) => order_to_tags(new_order, Some((0.0, 0, 0)), Some(&mostro_pubkey))?,
Err(_) => order_to_tags(new_order, Some((0.0, 0, 0)), &mostro_pubkey)?,
};

// Prepare new child order event for sending (kind 38383 for orders)
Expand Down
2 changes: 1 addition & 1 deletion src/bitcoin_price.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ impl BitcoinPriceManager {

/// Publishes exchange rates to Nostr as a NIP-33 addressable event (kind 30078)
async fn publish_rates_to_nostr(rates: &HashMap<String, f64>) -> Result<(), MostroError> {
let keys = get_keys().map_err(|e| {
let keys = get_keys().await.map_err(|e| {
error!("Failed to get Mostro keys: {}", e);
MostroInternalErr(ServiceError::IOError(e.to_string()))
})?;
Expand Down
23 changes: 20 additions & 3 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ mod tests {

// Fake settings for the test
const NOSTR_SETTINGS: &str = r#"[nostr]
nsec_privkey = 'nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd'
nsec_privkey_file = '/tmp/mostro-test-nostr-key.nsec'
relays = ['wss://relay.damus.io','wss://relay.mostro.network']"#;

const LIGHTNING_SETTINGS: &str = r#"[lightning]
Expand Down Expand Up @@ -194,15 +194,32 @@ mod tests {
let nostr_settings: StubSettingsNostr =
toml::from_str(NOSTR_SETTINGS).expect("Failed to deserialize");
assert_eq!(
nostr_settings.nostr.nsec_privkey,
"nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd"
nostr_settings.nostr.nsec_privkey_file,
"/tmp/mostro-test-nostr-key.nsec"
);
assert_eq!(nostr_settings.nostr.nsec_privkey, None);
assert_eq!(
nostr_settings.nostr.relays,
vec!["wss://relay.damus.io", "wss://relay.mostro.network"]
);
}

#[test]
fn test_nostr_settings_legacy_inline_key() {
let legacy_settings = r#"[nostr]
nsec_privkey = 'nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd'
relays = ['wss://relay.damus.io']"#;
let nostr_settings: StubSettingsNostr =
toml::from_str(legacy_settings).expect("Failed to deserialize");

assert_eq!(nostr_settings.nostr.nsec_privkey_file, "");
assert_eq!(
nostr_settings.nostr.nsec_privkey.as_deref(),
Some("nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd")
);
assert_eq!(nostr_settings.nostr.relays, vec!["wss://relay.damus.io"]);
Comment on lines +207 to +220
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use an obvious dummy value instead of a real-looking nsec fixture.

This test only exercises TOML deserialization, so the long nsec... string just trips secret scanners. Betterleaks is already flagging Line 210. Please swap this and the matching new literal in src/config/util.rs for a clearly fake placeholder.

Suggested tweak
-                                    nsec_privkey = 'nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd'
+                                    nsec_privkey = 'legacy-inline-key-for-test-only'
...
-            Some("nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd")
+            Some("legacy-inline-key-for-test-only")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn test_nostr_settings_legacy_inline_key() {
let legacy_settings = r#"[nostr]
nsec_privkey = 'nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd'
relays = ['wss://relay.damus.io']"#;
let nostr_settings: StubSettingsNostr =
toml::from_str(legacy_settings).expect("Failed to deserialize");
assert_eq!(nostr_settings.nostr.nsec_privkey_file, "");
assert_eq!(
nostr_settings.nostr.nsec_privkey.as_deref(),
Some("nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd")
);
assert_eq!(nostr_settings.nostr.relays, vec!["wss://relay.damus.io"]);
#[test]
fn test_nostr_settings_legacy_inline_key() {
let legacy_settings = r#"[nostr]
nsec_privkey = 'legacy-inline-key-for-test-only'
relays = ['wss://relay.damus.io']"#;
let nostr_settings: StubSettingsNostr =
toml::from_str(legacy_settings).expect("Failed to deserialize");
assert_eq!(nostr_settings.nostr.nsec_privkey_file, "");
assert_eq!(
nostr_settings.nostr.nsec_privkey.as_deref(),
Some("legacy-inline-key-for-test-only")
);
assert_eq!(nostr_settings.nostr.relays, vec!["wss://relay.damus.io"]);
🧰 Tools
🪛 Betterleaks (1.1.1)

[high] 210-210: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/mod.rs` around lines 207 - 220, Replace the real-looking nsec
string used for TOML deserialization with an obvious dummy placeholder in the
test function test_nostr_settings_legacy_inline_key (replace the value assigned
to legacy_settings where nsec_privkey is set) and update the corresponding
literal in src/config/util.rs so both places use the same clearly fake
placeholder; ensure the test still asserts the same fields
(nostr_settings.nostr.nsec_privkey_file, nostr_settings.nostr.nsec_privkey, and
nostr_settings.nostr.relays) but with the new placeholder value instead of the
long real-looking nsec string.

}

#[test]
fn test_mostro_settings() {
// Parse TOML content
Expand Down
8 changes: 6 additions & 2 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ pub struct LightningSettings {
/// Nostr configuration settings
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct NostrSettings {
/// Nostr private key
pub nsec_privkey: String,
/// Path to file containing the Nostr private key (nsec)
#[serde(default)]
pub nsec_privkey_file: String,
/// Legacy inline Nostr private key kept for backward compatibility.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsec_privkey: Option<String>,
/// Nostr relays list
pub relays: Vec<String>,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
62 changes: 62 additions & 0 deletions src/config/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ fn validate_mostro_settings(settings: &Settings) -> Result<(), MostroError> {
))));
}

if settings.nostr.nsec_privkey.is_some() {
return Err(MostroInternalErr(ServiceError::IOError(
"nostr.nsec_privkey is no longer supported; move the key to nostr.nsec_privkey_file"
.to_string(),
)));
}

if settings.nostr.nsec_privkey_file.trim().is_empty() {
return Err(MostroInternalErr(ServiceError::IOError(
"Missing Nostr private key file configuration".to_string(),
)));
}

Ok(())
}

Expand Down Expand Up @@ -100,3 +113,52 @@ pub fn init_configuration_file(config_path: Option<String>) -> Result<(), Mostro

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::types::{
DatabaseSettings, LightningSettings, MostroSettings, NostrSettings, RpcSettings,
};

fn make_settings(nostr: NostrSettings) -> Settings {
Settings {
database: DatabaseSettings::default(),
lightning: LightningSettings::default(),
nostr,
mostro: MostroSettings::default(),
rpc: RpcSettings::default(),
expiration: None,
}
}

#[test]
fn validate_mostro_settings_rejects_legacy_inline_nsec() {
let settings = make_settings(NostrSettings {
nsec_privkey: Some(
"nsec13as48eum93hkg7plv526r9gjpa0uc52zysqm93pmnkca9e69x6tsdjmdxd".to_string(),
),
relays: vec!["wss://relay.test".to_string()],
..Default::default()
});

let error = validate_mostro_settings(&settings).expect_err("inline nsec must be rejected");
assert!(error
.to_string()
.contains("nostr.nsec_privkey is no longer supported"));
}

#[test]
fn validate_mostro_settings_requires_private_key_file() {
let settings = make_settings(NostrSettings {
relays: vec!["wss://relay.test".to_string()],
..Default::default()
});

let error =
validate_mostro_settings(&settings).expect_err("missing key file must be rejected");
assert!(error
.to_string()
.contains("Missing Nostr private key file configuration"));
}
}
37 changes: 33 additions & 4 deletions src/config/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ fn run_setup_wizard(settings_dir: &Path, config_file_path: &Path) -> Result<Sett

println!("\n--- Nostr Configuration ---\n");

let nostr = prompt_nostr_settings()?;
let nostr = prompt_nostr_settings(settings_dir)?;

println!("\n--- Mostro Configuration ---\n");

Expand Down Expand Up @@ -142,14 +142,14 @@ fn prompt_lightning_settings() -> Result<LightningSettings, MostroError> {
})
}

fn prompt_nostr_settings() -> Result<NostrSettings, MostroError> {
fn prompt_nostr_settings(settings_dir: &Path) -> Result<NostrSettings, MostroError> {
let has_nsec = Confirm::new()
.with_prompt("Do you have an existing nsec key?")
.default(false)
.interact()
.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?;

let nsec_privkey = if has_nsec {
let nsec = if has_nsec {
Input::new()
.with_prompt("Enter your nsec private key")
.validate_with(|input: &String| validate_nsec(input))
Expand All @@ -174,6 +174,34 @@ fn prompt_nostr_settings() -> Result<NostrSettings, MostroError> {
nsec
};

// Write the nsec key to a file in the settings directory
let nsec_privkey_file = settings_dir.join("nostr-key.nsec");
{
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&nsec_privkey_file)
};
#[cfg(not(unix))]
let file = {
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&nsec_privkey_file)
};
let mut file = file.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?;
file.write_all(nsec.as_bytes())
.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

println!(" Private key saved to {}", nsec_privkey_file.display());

let relays_input: String = Input::new()
.with_prompt("Nostr relays (comma-separated)")
.default("wss://relay.mostro.network".to_string())
Expand All @@ -188,8 +216,9 @@ fn prompt_nostr_settings() -> Result<NostrSettings, MostroError> {
.collect();

Ok(NostrSettings {
nsec_privkey,
nsec_privkey_file: nsec_privkey_file.to_string_lossy().to_string(),
relays,
..Default::default()
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async fn main() -> Result<()> {
};

// Get mostro keys
let mostro_keys = util::get_keys()?;
let mostro_keys = util::get_keys().await?;

let subscription = Filter::new()
.pubkey(mostro_keys.public_key())
Expand Down
Loading