commit 1c99615d6172562cf8c8279cb7f1f4e568ec3e78
parent f484ebfd63a29d0e995c417ab7b42780669039e6
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 18:57:57 +0000
core(accounts): implement secret-key import command
- accept validated nsec and canonical secret-hex inputs
- derive and persist canonical public account identity in Rust
- select imported accounts without implicitly activating them
- reject invalid checksums before any credential or metadata write
Diffstat:
2 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs
@@ -2,9 +2,9 @@ use std::sync::{Mutex, MutexGuard};
use radroots_studio_domain::{
AccountCreatedAt, AccountSummary, KeyAvailability, Nsec, PublicKey, SafeError, SafeErrorCode,
- SafeMessage, SignerKind,
+ SafeMessage, SecretKeyInput, SignerKind,
};
-use radroots_studio_nostr::generate_local_keypair;
+use radroots_studio_nostr::{generate_local_keypair, import_secret};
use crate::{AccountRepository, AppCore, AppStateRepository, Clock, SecretStore, StateTransition};
@@ -13,6 +13,18 @@ pub struct GenerateAccountReceipt {
generated_nsec: Nsec,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ImportAccountReceipt {
+ account: AccountSummary,
+}
+
+impl ImportAccountReceipt {
+ #[must_use]
+ pub const fn account(&self) -> &AccountSummary {
+ &self.account
+ }
+}
+
impl GenerateAccountReceipt {
#[must_use]
pub const fn account(&self) -> &AccountSummary {
@@ -62,6 +74,40 @@ impl AppCore {
generated_nsec: nsec,
})
}
+
+ /// Imports, stores, and selects one local Nostr account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe key, credential, persistence, or application-state error.
+ pub fn import_secret_key(
+ &self,
+ input: SecretKeyInput,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<ImportAccountReceipt, SafeError> {
+ let imported = import_secret(input)?;
+ let (public_key, npub, secret) = imported.into_parts();
+ let account = AccountSummary::new(
+ public_key,
+ npub,
+ SignerKind::LocalSecret,
+ KeyAvailability::Available,
+ None,
+ AccountCreatedAt::new(clock.now()),
+ None,
+ );
+ secrets.put(public_key, secret)?;
+ accounts.insert_account(&account)?;
+ app_state.save_selected_account(Some(public_key))?;
+ self.apply_transition(StateTransition::ReplaceRegistry {
+ accounts: accounts.list_accounts()?,
+ selected: Some(public_key),
+ })?;
+ Ok(ImportAccountReceipt { account })
+ }
}
#[derive(Default)]
@@ -172,7 +218,7 @@ const fn account_not_found() -> SafeError {
#[cfg(test)]
mod tests {
- use radroots_studio_domain::UnixTimestamp;
+ use radroots_studio_domain::{SafeErrorCode, SecretKeyInput, UnixTimestamp};
use super::InMemoryAccountRepository;
use crate::{
@@ -211,4 +257,48 @@ mod tests {
assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63);
assert!(!format!("{:?}", core.snapshot()).contains("nsec1"));
}
+
+ #[test]
+ fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() {
+ for input in [
+ "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
+ "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
+ ] {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ core.bootstrap().expect("bootstrap");
+ let receipt = core
+ .import_secret_key(
+ SecretKeyInput::parse(input.to_owned()).expect("input"),
+ &accounts,
+ &accounts,
+ &secrets,
+ &FixedClock,
+ )
+ .expect("import");
+ let public_key = receipt.account().public_key();
+ assert!(secrets.contains(public_key).expect("credential"));
+ assert_eq!(core.snapshot().selected_account(), Some(public_key));
+ assert_eq!(core.snapshot().session(), SessionState::SignedOut);
+ assert!(!format!("{:?}", core.snapshot()).contains(input));
+ }
+ }
+
+ #[test]
+ fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = InMemorySecretStore::default();
+ core.bootstrap().expect("bootstrap");
+ let input = SecretKeyInput::parse(
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
+ )
+ .expect("domain shape");
+ let error = core
+ .import_secret_key(input, &accounts, &accounts, &secrets, &FixedClock)
+ .expect_err("invalid import");
+ assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
+ assert!(core.snapshot().accounts().is_empty());
+ }
}
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -7,7 +7,7 @@ pub mod secrets;
pub mod snapshot;
pub mod state_machine;
-pub use accounts::{GenerateAccountReceipt, InMemoryAccountRepository};
+pub use accounts::{GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository};
pub use app_core::{AppCore, AppObserver, ObserverHandle};
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,