Skip to content

Harden nsec storage with SecretString and one-time key init#772

Open
arkanoider wants to merge 4 commits into
mainfrom
harden/nsec-zeroization
Open

Harden nsec storage with SecretString and one-time key init#772
arkanoider wants to merge 4 commits into
mainfrom
harden/nsec-zeroization

Conversation

@arkanoider

@arkanoider arkanoider commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Wrap nsec_privkey in secrecy::SecretString with zeroize for transient buffers (TOML load, wizard input, env reads).
  • Parse Mostro Keys once at startup into NOSTR_KEYS, then wipe the nsec from global Settings so MOSTRO_CONFIG / Arc<Settings> clones no longer retain a duplicate plaintext copy.
  • Change get_keys() to return the cached &'static Keys instead of re-parsing on every call.

Residual exposure (unchanged)

  • Process environment (MOSTRO_NSEC_PRIVKEY) and on-disk .env / settings.toml remain plaintext by design.
  • nostr_sdk::Keys internal representation is not zeroized (upstream).

Summary by CodeRabbit

  • Security Improvements

    • Enhanced in-memory protection and automatic erasure of Nostr private keys.
    • Prevents accidental plaintext retention in configuration and during startup.
  • New Features

    • Setup wizard no longer prints private keys (only public identifier shown); secrets are stored/handled securely.
  • Bug Fixes

    • Initialization now surfaces key-related errors instead of silently proceeding.

Wrap the Nostr private key in secrecy/zeroize, parse Keys once at startup,
wipe nsec from global Settings, and stop re-parsing on every get_keys call.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 96179f15-de8e-4b63-8137-19c590f09878

📥 Commits

Reviewing files that changed from the base of the PR and between 0ccd55c and 12e8a8a.

📒 Files selected for processing (1)
  • src/price/manager.rs
✅ Files skipped from review due to trivial changes (1)
  • src/price/manager.rs

Walkthrough

This PR refactors Nostr private key handling to use secret-aware types and centralizes key initialization. Dependencies for secrecy and zeroize are added; a new config/secret.rs module provides parsing and serialization helpers; NostrSettings.nsec_privkey switches to SecretString; global NOSTR_KEYS storage is introduced; configuration initialization extracts and caches keys; TOML buffers and environment handling are updated for zeroization; wizard input is handled securely; and call sites are adjusted to use the new borrowed-keys API.

Changes

Nostr Secret Handling Refactor

Layer / File(s) Summary
Secret handling infrastructure: deps, types, and parsing helpers
Cargo.toml, src/config/secret.rs, src/config/mod.rs, src/config/types.rs
Dependencies for secrecy and zeroize are added; new config/secret.rs module implements read_nsec_env_var(), parse_mostro_keys(), take_nsec_for_init(), and serialize_nsec() for secure key handling; global NOSTR_KEYS: OnceLock<Keys> is introduced; NostrSettings.nsec_privkey field switches from String to SecretString with custom Serde serialization.
Configuration initialization and global key storage
src/config/settings.rs, src/app/context.rs, src/config/mod.rs (tests)
init_mostro_settings() signature changes to return Result<(), MostroError> and extract Nostr keys from settings, storing them in the global NOSTR_KEYS via OnceLock::set_once(); new get_mostro_keys() exposes initialized keys; test context builders and fixtures are updated to use SecretString handling via take_nsec_for_init().
Config file loading, env override, and wizard secrets
src/config/util.rs, src/config/wizard.rs
Settings.toml is loaded into a Zeroizing buffer to scrub plaintext after deserialization; environment-based nsec_privkey override refactored to use read_nsec_env_var(); initialization error propagated via ?; wizard wraps user input and generated nsec in Zeroizing/SecretString, removes plaintext console output, and returns SecretString from storage prompt without logging secrets.
get_keys() API refactor: from owned to borrowed Keys
src/util.rs
get_keys() return type changes from Result<Keys, MostroError> to Result<&'static Keys, MostroError>, sourced from global NOSTR_KEYS instead of parsing Settings on each call; returns MostroInternalErr(ServiceError::NostrError("Nostr keys not initialized")) when global not set; immediate callers (publish_dev_fee_audit_event, invoice_subscribe) updated to use borrowed reference.
Call site adjustments for key ownership semantics
src/app/bond/flow.rs, src/main.rs, src/db.rs, src/price/manager.rs
Bond flow methods (request_taker_bond, on_bond_invoice_accepted, on_maker_bond_accepted, maybe_drop_waiting_taker_bond) now pass my_keys by value instead of reference; main.rs metadata signing passes mostro_keys by value to sign_with_keys(); price publish and util signing calls updated to match new API; db.rs Unix permission imports scoped to #[cfg(unix)].

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • MostroP2P/mostro#713: Overlapping changes to nsec_privkey handling, env-var parsing, and TOML/init behavior.
  • MostroP2P/mostro#481: Prior settings/config refactor touching NostrSettings and initialization used here.
  • MostroP2P/mostro#670: Related key access and flow parameterization changes affecting hold_invoice_paid and key propagation.

Suggested Reviewers

  • Catrya
  • grunch

Poem

🐰 I hid the key in velvet green and string,
Wrapped it up in SecretString to cling,
Zeroized the pages of the config book,
OnceLock keeps keys—no need to look,
A tiny hop for safety—what a spring!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main changes: hardening nsec storage using SecretString and implementing one-time key initialization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch harden/nsec-zeroization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkanoider arkanoider requested review from Catrya and grunch June 11, 2026 14:34
@mostronatorcoder

Copy link
Copy Markdown
Contributor

I did a strict pass on this PR and I do not currently see a functional blocker.

What I specifically checked:

  • the new SecretString / zeroize path for nsec_privkey
  • the one-time initialization flow in init_mostro_settings()
  • the switch from reparsing on every get_keys() call to using cached NOSTR_KEYS
  • whether any obvious runtime path still depends on reading the plaintext nsec back out of global Settings after it has been cleared

From what I can see, the design is internally consistent:

  • the nsec is parsed once during startup
  • Settings.nostr.nsec_privkey is then cleared so cloned/shared settings no longer retain a duplicate plaintext copy
  • runtime callers move to get_keys() / get_mostro_keys() instead of reparsing the secret repeatedly

I also appreciate that the PR description is honest about the residual exposure that still exists by design (.env, settings.toml, process env, and whatever nostr_sdk::Keys retains internally).

So from my side this looks like a sound hardening step with a clear security win, even if it is intentionally not the final word on secret handling.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/util.rs (1)

820-826: ⚡ Quick win

Document the get_keys initialization contract.

This public API now has a non-obvious precondition (NOSTR_KEYS must be initialized) and a specific failure mode; please add /// docs so callers know the lifecycle and error semantics.

✍️ Suggested doc block
+/// Returns the globally initialized Mostro Nostr signing keys.
+///
+/// # Errors
+/// Returns `MostroError` if keys have not been initialized yet
+/// (i.e. before settings/bootstrap finishes).
 pub fn get_keys() -> Result<&'static Keys, MostroError> {

As per coding guidelines, "Document non-obvious public APIs with /// doc comments".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/util.rs` around lines 820 - 826, Add a doc comment to the public function
get_keys that documents its initialization contract and failure semantics: state
that callers must ensure NOSTR_KEYS (or the underlying provider initialized by
crate::config::get_mostro_keys) is set before calling, describe that it returns
a &'static Keys on success and returns a MostroError via
MostroInternalErr(ServiceError::NostrError(...)) when keys are not initialized,
and include any recommended lifecycle guidance (when/where to call
initialization) so callers understand preconditions and error handling for
get_keys.

Source: Coding guidelines

src/app/context.rs (1)

233-238: ⚡ Quick win

Keep test settings on the same post-init contract as production.

The builder still parses settings.nostr.nsec_privkey directly and then stores that same populated Settings inside Arc. Production goes through take_nsec_for_init() and clears the settings copy, so tests can currently mask code that still reads the secret from settings instead of ctx.keys().

Based on PR objectives, shared settings clones should no longer retain a duplicate plaintext nsec copy.

Also applies to: 263-265

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/context.rs` around lines 233 - 238, The TestContextBuilder currently
parses settings.nostr.nsec_privkey directly and then passes the original
Settings into AppContext, leaving a plaintext nsec in the shared Settings;
instead call the same helper used in production (take_nsec_for_init or
equivalent) to extract and clear the nsec into Keys and then pass the sanitized
Settings into AppContext (replace the unwrap_or_else parsing with code that
invokes take_nsec_for_init on settings to populate keys and return a Settings
without the nsec). Apply the same change to the similar block around lines
263-265 so tests mirror production behavior and no Settings clone retains the
plaintext nsec.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config/secret.rs`:
- Around line 22-29: The env path in read_nsec_env_var leaves the raw String
buffer in memory; make the initial nsec_from_env mutable, import
zeroize::Zeroize, build the SecretString from a trimmed owned copy (e.g.
SecretString::from(nsec_from_env.trim().to_owned())), then immediately call
nsec_from_env.zeroize() before returning; this wipes the original env buffer
while still returning the SecretString from the trimmed owned data.

In `@src/config/settings.rs`:
- Around line 37-45: Change init_mostro_settings to return a Result (e.g.,
Result<(), anyhow::Error> or your crate's error type) instead of panicking:
replace the expect() calls around take_nsec_for_init, NOSTR_KEYS.set, and
MOSTRO_CONFIG.set with early-return Err variants containing contextual errors;
keep using take_nsec_for_init, NOSTR_KEYS.set, and MOSTRO_CONFIG.set to detect
failures but propagate them via Err(...) (with context) and return Ok(()) on
success; then update callers (e.g., the call site that currently invokes
init_mostro_settings(settings)) to use the ? operator so failures flow through
the existing init_configuration_file() Result path.

In `@src/config/wizard.rs`:
- Around line 158-165: Replace the visible text input for existing nsec in
prompt_nostr_settings: use a non-echoing prompt
(Password::new().with_prompt(...).validate_with(|s: &String|
validate_nsec(s)).interact_password() or the equivalent non-echoing API) instead
of Input::new().interact_text(), then wrap the result in Zeroizing and construct
SecretString::from(...) as before. Also stop printing the raw nsec to stdout
when generating a new keypair; only display the public key (npub) and instruct
the user to securely store the private key (or write it to a secure
file/clipboard) so the nsec is never printed in plain text by the wizard.

---

Nitpick comments:
In `@src/app/context.rs`:
- Around line 233-238: The TestContextBuilder currently parses
settings.nostr.nsec_privkey directly and then passes the original Settings into
AppContext, leaving a plaintext nsec in the shared Settings; instead call the
same helper used in production (take_nsec_for_init or equivalent) to extract and
clear the nsec into Keys and then pass the sanitized Settings into AppContext
(replace the unwrap_or_else parsing with code that invokes take_nsec_for_init on
settings to populate keys and return a Settings without the nsec). Apply the
same change to the similar block around lines 263-265 so tests mirror production
behavior and no Settings clone retains the plaintext nsec.

In `@src/util.rs`:
- Around line 820-826: Add a doc comment to the public function get_keys that
documents its initialization contract and failure semantics: state that callers
must ensure NOSTR_KEYS (or the underlying provider initialized by
crate::config::get_mostro_keys) is set before calling, describe that it returns
a &'static Keys on success and returns a MostroError via
MostroInternalErr(ServiceError::NostrError(...)) when keys are not initialized,
and include any recommended lifecycle guidance (when/where to call
initialization) so callers understand preconditions and error handling for
get_keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d61c4e5-3201-46a2-9cb8-1d4d9f769416

📥 Commits

Reviewing files that changed from the base of the PR and between eed5ede and 8ffb2c3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • Cargo.toml
  • src/app/bond/flow.rs
  • src/app/context.rs
  • src/bitcoin_price.rs
  • src/config/mod.rs
  • src/config/secret.rs
  • src/config/settings.rs
  • src/config/types.rs
  • src/config/util.rs
  • src/config/wizard.rs
  • src/db.rs
  • src/main.rs
  • src/util.rs

Comment thread src/config/secret.rs
Comment thread src/config/settings.rs Outdated
Comment thread src/config/wizard.rs
@ermeme

ermeme Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Reviewed this PR — the SecretString/Zeroizing refactor looks clean, and I did not spot any blocking issues. Good move parsing the Nostr keys once at startup and wiping the duplicate plaintext from global settings.

@codaMW codaMW left a comment

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.

Tested on regtest, and verified the specific hardening claims rather than just "nothing regressed." ACK.

Build + suite
• Builds clean (the get_keys() owned->borrowed change and the updated call sites in bond/flow.rs, main.rs, db.rs, price/manager.rs all compile).
• Full suite: 403 pass. The single failure is test_lnurl_validation_with_test_server (AddrInUse / port bind) it's in the Lightning subsystem this PR doesn't touch and fails identically on main, so it's a pre-existing local-env flake, not from this PR.

Runtime (no regression)
• Ran the daemon on the branch: starts cleanly (one-time NOSTR_KEYS OnceLock init via take_nsec_for_init), and signs + publishes to Nostr fine (saw a kind-30078 price event published). So signing through the refactored cached get_keys() works after the nsec is parsed once and the Settings copy is cleared no "keys not initialized" / NostrError / signing failures in the log.

Hardening claims actually verified
• nsec wiped from Settings after init: take_nsec_for_init does std::mem::take(&mut nostr.nsec_privkey) then overwrites with SecretString::default(); the test take_nsec_clears_settings_field asserts nsec_privkey is empty afterward passes here.
• redaction: the field is secrecy::SecretString (types.rs:284), so an accidental {:?} of NostrSettings prints [REDACTED] rather than the key.
• no plaintext leak: grep finds zero expose_secret() calls in any logging/print/format path; the wizard prints only the npub ("Generated new Nostr keypair: npub: …"), never the nsec.
• bonus: serialize_omits_secret_fields also passes secrets aren't serialized out of the bond model.

Scope (honest): I verified the behaviors the PR claims Settings is cleared post-init, secrets are redacted in Debug, and nothing logs the plaintext via the included tests + code paths, plus a runtime no-regression check. I did not do a raw memory dump to prove the zeroize buffers are scrubbed from RAM.

Housekeeping: the branch currently shows merge conflicts against main needs a rebase before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants