commit 7b09c7b57bfaf34d60f86b42501b0fd3d05f63b8
parent 34f52b9c63960736c6474439f3d91050b70345d7
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 17:38:47 +0000
core: make local secret custody encrypted-first
- add encrypted and raw secret import modes with encrypted-first setup and home flows
- add password-backed secret key backup state and advanced raw secret reveal handling
- clear raw secret material on timeout and when the app loses focus
- update shared test support and web fallback for the new secret key action contract
Diffstat:
6 files changed, 577 insertions(+), 78 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2851,6 +2851,7 @@ dependencies = [
"log",
"ndk-context",
"radroots-app-core",
+ "radroots-app-test-support",
"radroots-geocoder",
"radroots-identity",
"radroots-nostr-accounts",
@@ -2890,6 +2891,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"radroots-app-apple-security",
"radroots-app-core",
+ "radroots-app-test-support",
"radroots-geocoder",
"radroots-identity",
"radroots-nostr-accounts",
diff --git a/Cargo.toml b/Cargo.toml
@@ -34,7 +34,7 @@ nostr-browser-signer = "0.44.1"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] }
radroots-app-apple-security = { path = "crates/apple/security" }
radroots-geocoder = { path = "../lib/crates/geocoder" }
-radroots-identity = { path = "../lib/crates/identity", default-features = false, features = ["std"] }
+radroots-identity = { path = "../lib/crates/identity", default-features = false, features = ["std", "nip49"] }
radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] }
wasm-bindgen-futures = "0.4.50"
web-sys = { version = "0.3.91", features = ["Document", "HtmlCanvasElement", "Window"] }
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -1,13 +1,14 @@
#![forbid(unsafe_code)]
use eframe::egui;
-use std::time::Duration;
+use std::time::{Duration, Instant};
use zeroize::Zeroizing;
mod account_roster;
mod home_location_tools;
mod location_resolver;
mod offline_geocoder;
+mod secret_keys;
pub const APP_NAME: &str = "Rad Roots";
@@ -21,6 +22,7 @@ pub use offline_geocoder::{
RadrootsOfflineGeocoderDiagnostic, RadrootsOfflineGeocoderPlatform,
RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind,
};
+pub use secret_keys::{RadrootsSecretImportMode, RadrootsSecretImportRequest};
use home_location_tools::HomeLocationTools;
@@ -56,6 +58,7 @@ pub struct HomeActionState {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HomeActionKind {
BackupSecretKey,
+ RevealRawSecretKey,
RemoveLocalKey,
ResetDevice,
DisconnectSigner,
@@ -65,7 +68,8 @@ pub enum HomeActionKind {
pub enum HomeActionResult {
None,
IdentityState(IdentityGateState),
- RevealSecretKey { nsec: String },
+ RevealEncryptedSecretKey { ncryptsec: String },
+ RevealRawSecretKey { nsec: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -99,7 +103,7 @@ pub trait RadrootsAppBackend {
}
fn request_import_action(
&self,
- _secret_key: &str,
+ _request: &RadrootsSecretImportRequest,
) -> Result<Option<IdentityGateState>, String> {
Ok(None)
}
@@ -115,6 +119,12 @@ pub trait RadrootsAppBackend {
fn request_home_action(&self, _action: HomeActionKind) -> Result<HomeActionResult, String> {
Ok(HomeActionResult::None)
}
+ fn request_secret_key_backup_action(
+ &self,
+ _password: &str,
+ ) -> Result<HomeActionResult, String> {
+ Ok(HomeActionResult::None)
+ }
fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> {
Ok(None)
}
@@ -184,6 +194,53 @@ enum AppScreen {
Home { account_id: String },
}
+const RAW_SECRET_REVEAL_TIMEOUT: Duration = Duration::from_secs(30);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RevealedSecretMaterial {
+ EncryptedSecretKey(Zeroizing<String>),
+ RawSecretKey {
+ nsec: Zeroizing<String>,
+ revealed_at: Instant,
+ },
+}
+
+impl RevealedSecretMaterial {
+ fn label(&self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey(_) => "Encrypted Secret Key",
+ Self::RawSecretKey { .. } => "Raw Secret Key",
+ }
+ }
+
+ fn value(&self) -> &str {
+ match self {
+ Self::EncryptedSecretKey(ncryptsec) => ncryptsec.as_str(),
+ Self::RawSecretKey { nsec, .. } => nsec.as_str(),
+ }
+ }
+
+ fn dismiss_label(&self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey(_) => "Dismiss Encrypted Secret Key",
+ Self::RawSecretKey { .. } => "Dismiss Raw Secret Key",
+ }
+ }
+
+ fn is_raw(&self) -> bool {
+ matches!(self, Self::RawSecretKey { .. })
+ }
+
+ fn raw_secret_expired(&self) -> bool {
+ match self {
+ Self::RawSecretKey { revealed_at, .. } => {
+ revealed_at.elapsed() >= RAW_SECRET_REVEAL_TIMEOUT
+ }
+ Self::EncryptedSecretKey(_) => false,
+ }
+ }
+}
+
pub struct RadrootsApp {
backend: Box<dyn RadrootsAppBackend>,
screen: AppScreen,
@@ -192,12 +249,136 @@ pub struct RadrootsApp {
status_message: Option<String>,
home_location_tools: HomeLocationTools,
pending_home_confirmation: Option<HomeActionKind>,
- pending_import_entry: bool,
+ pending_import_mode: Option<RadrootsSecretImportMode>,
secret_key_input: Zeroizing<String>,
- revealed_secret_key: Option<Zeroizing<String>>,
+ import_password_input: Zeroizing<String>,
+ pending_secret_key_backup_entry: bool,
+ secret_key_backup_password_input: Zeroizing<String>,
+ secret_key_backup_password_confirm_input: Zeroizing<String>,
+ revealed_secret_material: Option<RevealedSecretMaterial>,
}
impl RadrootsApp {
+ fn clear_secret_import_entry(&mut self) {
+ self.pending_import_mode = None;
+ self.secret_key_input.clear();
+ self.import_password_input.clear();
+ }
+
+ fn clear_secret_key_backup_entry(&mut self) {
+ self.pending_secret_key_backup_entry = false;
+ self.secret_key_backup_password_input.clear();
+ self.secret_key_backup_password_confirm_input.clear();
+ }
+
+ fn clear_revealed_secret_material(&mut self) {
+ self.revealed_secret_material = None;
+ }
+
+ fn clear_secret_key_ui_state(&mut self) {
+ self.clear_secret_import_entry();
+ self.clear_secret_key_backup_entry();
+ self.clear_revealed_secret_material();
+ }
+
+ fn open_import_entry(&mut self) {
+ self.pending_import_mode = Some(RadrootsSecretImportMode::EncryptedSecretKey);
+ self.secret_key_input.clear();
+ self.import_password_input.clear();
+ self.status_message = None;
+ }
+
+ fn import_mode(&self) -> RadrootsSecretImportMode {
+ self.pending_import_mode.unwrap_or_default()
+ }
+
+ fn set_import_mode(&mut self, mode: RadrootsSecretImportMode) {
+ self.pending_import_mode = Some(mode);
+ self.secret_key_input.clear();
+ self.import_password_input.clear();
+ self.status_message = None;
+ }
+
+ fn secret_import_request(&self) -> Result<RadrootsSecretImportRequest, String> {
+ let mode = self.import_mode();
+ let secret_text = self.secret_key_input.trim().to_owned();
+ if secret_text.is_empty() {
+ return Err(match mode {
+ RadrootsSecretImportMode::EncryptedSecretKey => {
+ "enter an encrypted secret key to continue".to_owned()
+ }
+ RadrootsSecretImportMode::RawSecretKey => {
+ "enter a raw secret key to continue".to_owned()
+ }
+ });
+ }
+
+ let password = if mode.requires_password() {
+ if self.import_password_input.is_empty() {
+ return Err("enter a password to import the encrypted secret key".to_owned());
+ }
+ Some(self.import_password_input.to_string())
+ } else {
+ None
+ };
+
+ Ok(RadrootsSecretImportRequest {
+ mode,
+ secret_text,
+ password,
+ })
+ }
+
+ fn request_secret_key_backup_action(&mut self) {
+ self.status_message = None;
+ self.clear_revealed_secret_material();
+
+ if self.secret_key_backup_password_input.is_empty() {
+ self.status_message =
+ Some("enter a password to create an encrypted secret key backup".to_owned());
+ return;
+ }
+
+ if self.secret_key_backup_password_input != self.secret_key_backup_password_confirm_input {
+ self.status_message = Some("backup passwords do not match".to_owned());
+ return;
+ }
+
+ match self
+ .backend
+ .request_secret_key_backup_action(self.secret_key_backup_password_input.as_str())
+ {
+ Ok(result) => {
+ self.clear_secret_key_backup_entry();
+ self.apply_home_action_result(result);
+ }
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
+ fn sync_revealed_secret_material_lifetime(&mut self) {
+ if self
+ .revealed_secret_material
+ .as_ref()
+ .is_some_and(RevealedSecretMaterial::raw_secret_expired)
+ {
+ self.clear_revealed_secret_material();
+ }
+ }
+
+ fn clear_raw_secret_when_app_unfocused(&mut self, ctx: &egui::Context) {
+ if self
+ .revealed_secret_material
+ .as_ref()
+ .is_some_and(RevealedSecretMaterial::is_raw)
+ && ctx.input(|input| input.viewport().focused == Some(false))
+ {
+ self.clear_revealed_secret_material();
+ }
+ }
+
pub fn new(backend: Box<dyn RadrootsAppBackend>) -> Self {
let mut app = Self {
backend,
@@ -207,9 +388,13 @@ impl RadrootsApp {
status_message: None,
home_location_tools: HomeLocationTools::new(),
pending_home_confirmation: None,
- pending_import_entry: false,
+ pending_import_mode: None,
secret_key_input: Zeroizing::new(String::new()),
- revealed_secret_key: None,
+ import_password_input: Zeroizing::new(String::new()),
+ pending_secret_key_backup_entry: false,
+ secret_key_backup_password_input: Zeroizing::new(String::new()),
+ secret_key_backup_password_confirm_input: Zeroizing::new(String::new()),
+ revealed_secret_material: None,
};
app.offline_geocoder_state = app.backend.offline_geocoder_state();
match app.backend.load_identity_state() {
@@ -242,9 +427,7 @@ impl RadrootsApp {
self.status_message = None;
self.home_location_tools.clear();
self.pending_home_confirmation = None;
- self.pending_import_entry = false;
- self.secret_key_input.clear();
- self.revealed_secret_key = None;
+ self.clear_secret_key_ui_state();
}
IdentityGateState::Ready { account_id } => {
self.screen = AppScreen::Home { account_id };
@@ -252,9 +435,7 @@ impl RadrootsApp {
self.refresh_account_roster();
self.home_location_tools.clear();
self.pending_home_confirmation = None;
- self.pending_import_entry = false;
- self.secret_key_input.clear();
- self.revealed_secret_key = None;
+ self.clear_secret_key_ui_state();
}
IdentityGateState::Unsupported { reason } => {
self.screen = AppScreen::Setup;
@@ -262,16 +443,14 @@ impl RadrootsApp {
self.status_message = Some(reason);
self.home_location_tools.clear();
self.pending_home_confirmation = None;
- self.pending_import_entry = false;
- self.secret_key_input.clear();
- self.revealed_secret_key = None;
+ self.clear_secret_key_ui_state();
}
}
}
fn request_setup_action(&mut self) {
self.status_message = None;
- self.revealed_secret_key = None;
+ self.clear_revealed_secret_material();
match self.backend.request_setup_action() {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => {}
@@ -283,10 +462,9 @@ impl RadrootsApp {
fn request_home_setup_action(&mut self) {
self.status_message = None;
- self.revealed_secret_key = None;
+ self.clear_revealed_secret_material();
self.pending_home_confirmation = None;
- self.pending_import_entry = false;
- self.secret_key_input.clear();
+ self.clear_secret_import_entry();
match self.backend.request_home_setup_action() {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => self.refresh_account_roster(),
@@ -298,11 +476,15 @@ impl RadrootsApp {
fn request_import_action(&mut self) {
self.status_message = None;
- self.revealed_secret_key = None;
- match self
- .backend
- .request_import_action(self.secret_key_input.trim())
- {
+ self.clear_revealed_secret_material();
+ let request = match self.secret_import_request() {
+ Ok(request) => request,
+ Err(err) => {
+ self.status_message = Some(err);
+ return;
+ }
+ };
+ match self.backend.request_import_action(&request) {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => {}
Err(err) => {
@@ -313,7 +495,7 @@ impl RadrootsApp {
fn request_import_paste_action(&mut self) {
self.status_message = None;
- self.revealed_secret_key = None;
+ self.clear_revealed_secret_material();
match self.backend.request_import_paste_action() {
Ok(Some(secret_key)) => {
self.secret_key_input = Zeroizing::new(secret_key);
@@ -327,10 +509,9 @@ impl RadrootsApp {
fn request_select_account(&mut self, account_id: &str) {
self.status_message = None;
- self.revealed_secret_key = None;
+ self.clear_revealed_secret_material();
self.pending_home_confirmation = None;
- self.pending_import_entry = false;
- self.secret_key_input.clear();
+ self.clear_secret_key_ui_state();
match self.backend.request_select_account(account_id) {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => self.refresh_account_roster(),
@@ -342,7 +523,7 @@ impl RadrootsApp {
fn request_home_action(&mut self, action: HomeActionKind) {
self.status_message = None;
- self.revealed_secret_key = None;
+ self.clear_revealed_secret_material();
match self.backend.request_home_action(action) {
Ok(result) => self.apply_home_action_result(result),
Err(err) => {
@@ -354,8 +535,17 @@ impl RadrootsApp {
fn apply_home_action_result(&mut self, result: HomeActionResult) {
match result {
HomeActionResult::IdentityState(state) => self.apply_identity_state(state),
- HomeActionResult::RevealSecretKey { nsec } => {
- self.revealed_secret_key = Some(Zeroizing::new(nsec));
+ HomeActionResult::RevealEncryptedSecretKey { ncryptsec } => {
+ self.revealed_secret_material = Some(RevealedSecretMaterial::EncryptedSecretKey(
+ Zeroizing::new(ncryptsec),
+ ));
+ self.pending_home_confirmation = None;
+ }
+ HomeActionResult::RevealRawSecretKey { nsec } => {
+ self.revealed_secret_material = Some(RevealedSecretMaterial::RawSecretKey {
+ nsec: Zeroizing::new(nsec),
+ revealed_at: Instant::now(),
+ });
self.pending_home_confirmation = None;
}
HomeActionResult::None => {}
@@ -369,7 +559,10 @@ impl RadrootsApp {
fn home_action_confirmation_message(action: HomeActionKind) -> &'static str {
match action {
HomeActionKind::BackupSecretKey => {
- "This reveals the current local secret key for backup. Do not share it."
+ "This exports the current local secret key in encrypted form for backup."
+ }
+ HomeActionKind::RevealRawSecretKey => {
+ "This reveals the current local secret key in plaintext. Use encrypted backup instead when possible."
}
HomeActionKind::RemoveLocalKey => {
"This removes the current key from this device and returns the app to setup."
@@ -438,15 +631,29 @@ impl RadrootsApp {
import_action: &ImportActionState,
import_paste_action: Option<&PasteActionState>,
) {
+ let import_mode = self.import_mode();
ui.vertical_centered(|ui| {
ui.set_max_width(ui.available_width().min(560.0));
- ui.label("Import an existing local identity by entering its nsec secret key.");
+ ui.label(import_mode.helper_text());
+ ui.add_space(8.0);
+ if ui.button(import_mode.switch_label()).clicked() {
+ self.set_import_mode(import_mode.toggle());
+ }
ui.add_space(8.0);
ui.add(
egui::TextEdit::singleline(&mut *self.secret_key_input)
- .hint_text("nsec1...")
+ .hint_text(import_mode.hint_text())
.desired_width(ui.available_width()),
);
+ if import_mode.requires_password() {
+ ui.add_space(8.0);
+ ui.add(
+ egui::TextEdit::singleline(&mut *self.import_password_input)
+ .password(true)
+ .hint_text("Enter Backup Password")
+ .desired_width(ui.available_width()),
+ );
+ }
ui.add_space(8.0);
if let Some(import_paste_action) = import_paste_action {
let paste_clicked = ui
@@ -472,8 +679,42 @@ impl RadrootsApp {
}
if ui.button("Cancel").clicked() {
- self.pending_import_entry = false;
- self.secret_key_input.clear();
+ self.clear_secret_import_entry();
+ self.status_message = None;
+ }
+ });
+ });
+ }
+
+ fn render_secret_key_backup_entry(&mut self, ui: &mut egui::Ui, action: &HomeActionState) {
+ ui.vertical_centered(|ui| {
+ ui.set_max_width(ui.available_width().min(560.0));
+ ui.label("Create an encrypted backup of the current local secret key.");
+ ui.add_space(8.0);
+ ui.add(
+ egui::TextEdit::singleline(&mut *self.secret_key_backup_password_input)
+ .password(true)
+ .hint_text("Enter Backup Password")
+ .desired_width(ui.available_width()),
+ );
+ ui.add_space(8.0);
+ ui.add(
+ egui::TextEdit::singleline(&mut *self.secret_key_backup_password_confirm_input)
+ .password(true)
+ .hint_text("Confirm Backup Password")
+ .desired_width(ui.available_width()),
+ );
+ ui.add_space(8.0);
+ ui.horizontal_centered(|ui| {
+ let confirm_clicked = ui
+ .add_enabled(action.enabled, egui::Button::new(action.label.clone()))
+ .clicked();
+ if confirm_clicked {
+ self.request_secret_key_backup_action();
+ }
+
+ if ui.button("Cancel").clicked() {
+ self.clear_secret_key_backup_entry();
self.status_message = None;
}
});
@@ -563,11 +804,10 @@ impl RadrootsApp {
}
}
ui.add_space(8.0);
- if self.pending_import_entry {
+ if self.pending_import_mode.is_some() {
self.render_import_entry(ui, &import_action, import_paste_action.as_ref());
} else if ui.button(import_action.label).clicked() {
- self.pending_import_entry = true;
- self.status_message = None;
+ self.open_import_entry();
}
}
}
@@ -612,6 +852,8 @@ impl RadrootsApp {
impl eframe::App for RadrootsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.sync_backend();
+ self.sync_revealed_secret_material_lifetime();
+ self.clear_raw_secret_when_app_unfocused(ctx);
if matches!(
self.offline_geocoder_state,
Some(RadrootsOfflineGeocoderState::Initializing)
@@ -621,6 +863,13 @@ impl eframe::App for RadrootsApp {
if self.home_location_tools.is_pending() {
ctx.request_repaint_after(Duration::from_millis(100));
}
+ if self
+ .revealed_secret_material
+ .as_ref()
+ .is_some_and(RevealedSecretMaterial::is_raw)
+ {
+ ctx.request_repaint_after(Duration::from_millis(200));
+ }
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
@@ -660,15 +909,14 @@ impl eframe::App for RadrootsApp {
if let Some(import_action) = import_action {
ui.add_space(12.0);
- if self.pending_import_entry {
+ if self.pending_import_mode.is_some() {
self.render_import_entry(
ui,
&import_action,
import_paste_action.as_ref(),
);
} else if ui.button(import_action.label).clicked() {
- self.pending_import_entry = true;
- self.status_message = None;
+ self.open_import_entry();
}
}
}
@@ -687,7 +935,23 @@ impl eframe::App for RadrootsApp {
ctx.request_repaint();
}
- if Self::home_action_requires_confirmation(action.kind)
+ if action.kind == HomeActionKind::BackupSecretKey
+ && self.pending_secret_key_backup_entry
+ {
+ self.render_secret_key_backup_entry(ui, &action);
+ } else if action.kind == HomeActionKind::BackupSecretKey
+ && ui
+ .add_enabled(
+ action.enabled,
+ egui::Button::new(action.label.clone()),
+ )
+ .clicked()
+ {
+ self.pending_secret_key_backup_entry = true;
+ self.secret_key_backup_password_input.clear();
+ self.secret_key_backup_password_confirm_input.clear();
+ self.status_message = None;
+ } else if Self::home_action_requires_confirmation(action.kind)
&& self.pending_home_confirmation == Some(action.kind)
{
ui.vertical_centered(|ui| {
@@ -728,14 +992,29 @@ impl eframe::App for RadrootsApp {
}
}
- if let Some(nsec) = &self.revealed_secret_key {
+ if let Some((label, value, dismiss_label, is_raw)) =
+ self.revealed_secret_material.as_ref().map(|material| {
+ (
+ material.label(),
+ material.value().to_owned(),
+ material.dismiss_label(),
+ material.is_raw(),
+ )
+ })
+ {
ui.add_space(20.0);
- ui.label("Secret key");
+ ui.label(label);
ui.add_space(8.0);
- ui.monospace(nsec.as_str());
+ ui.monospace(value);
+ if is_raw {
+ ui.add_space(8.0);
+ ui.label(
+ "Raw secret reveal clears automatically after 30 seconds or when the app loses focus.",
+ );
+ }
ui.add_space(8.0);
- if ui.button("Dismiss Secret Key").clicked() {
- self.revealed_secret_key = None;
+ if ui.button(dismiss_label).clicked() {
+ self.clear_revealed_secret_material();
}
}
}
@@ -755,7 +1034,9 @@ impl eframe::App for RadrootsApp {
#[cfg(test)]
mod tests {
use super::*;
- use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB};
+ use radroots_app_test_support::{
+ FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD, FIXTURE_BOB, fixture_identity_ncryptsec,
+ };
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
@@ -776,6 +1057,7 @@ mod tests {
home_setup_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
import_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
import_paste_request: Rc<RefCell<VecDeque<Result<Option<String>, String>>>>,
+ secret_key_backup_request: Rc<RefCell<VecDeque<Result<HomeActionResult, String>>>>,
home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>,
home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>,
reverse_lookup_request: Rc<RefCell<VecDeque<Result<(), RadrootsLocationResolverError>>>>,
@@ -807,6 +1089,7 @@ mod tests {
home_setup_request: Rc::new(RefCell::new(VecDeque::new())),
import_request: Rc::new(RefCell::new(VecDeque::new())),
import_paste_request: Rc::new(RefCell::new(VecDeque::new())),
+ secret_key_backup_request: Rc::new(RefCell::new(VecDeque::new())),
home_request: Rc::new(RefCell::new(VecDeque::new())),
home_poll: Rc::new(RefCell::new(VecDeque::new())),
reverse_lookup_request: Rc::new(RefCell::new(VecDeque::new())),
@@ -877,6 +1160,14 @@ mod tests {
self
}
+ fn with_secret_key_backup_request(
+ self,
+ request: Vec<Result<HomeActionResult, String>>,
+ ) -> Self {
+ self.secret_key_backup_request.borrow_mut().extend(request);
+ self
+ }
+
fn with_home_action_poll(
self,
poll: Vec<Result<Option<HomeActionResult>, String>>,
@@ -959,7 +1250,7 @@ mod tests {
fn request_import_action(
&self,
- _secret_key: &str,
+ _request: &RadrootsSecretImportRequest,
) -> Result<Option<IdentityGateState>, String> {
self.import_request
.borrow_mut()
@@ -967,6 +1258,16 @@ mod tests {
.unwrap_or(Ok(None))
}
+ fn request_secret_key_backup_action(
+ &self,
+ _password: &str,
+ ) -> Result<HomeActionResult, String> {
+ self.secret_key_backup_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(HomeActionResult::None))
+ }
+
fn import_paste_action_state(&self) -> Option<PasteActionState> {
self.import_paste_action_state.borrow().clone()
}
@@ -1395,6 +1696,9 @@ mod tests {
#[test]
fn import_action_transitions_to_home() {
+ let encrypted_secret_key =
+ fixture_identity_ncryptsec(&FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD)
+ .expect("fixture encrypted secret key");
let mut app = RadrootsApp::new(Box::new(
MockBackend::new(
Ok(IdentityGateState::Missing),
@@ -1416,13 +1720,15 @@ mod tests {
),
));
- app.pending_import_entry = true;
- app.secret_key_input = Zeroizing::new(FIXTURE_ALICE.nsec.into());
+ app.pending_import_mode = Some(RadrootsSecretImportMode::EncryptedSecretKey);
+ app.secret_key_input = Zeroizing::new(encrypted_secret_key);
+ app.import_password_input = Zeroizing::new(FIXTURE_BACKUP_PASSWORD.into());
app.request_import_action();
assert_eq!(app.screen, fixture_home_screen());
- assert_eq!(app.pending_import_entry, false);
+ assert_eq!(app.pending_import_mode, None);
assert_eq!(app.secret_key_input.as_str(), "");
+ assert_eq!(app.import_password_input.as_str(), "");
}
#[test]
@@ -1456,7 +1762,7 @@ mod tests {
),
));
- app.pending_import_entry = true;
+ app.pending_import_mode = Some(RadrootsSecretImportMode::EncryptedSecretKey);
app.request_import_paste_action();
assert_eq!(app.secret_key_input.as_str(), FIXTURE_ALICE.nsec);
@@ -1464,7 +1770,10 @@ mod tests {
}
#[test]
- fn backup_home_action_reveals_secret_key_without_leaving_home() {
+ fn encrypted_backup_home_action_reveals_secret_key_without_leaving_home() {
+ let encrypted_secret_key =
+ fixture_identity_ncryptsec(&FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD)
+ .expect("fixture encrypted secret key");
let mut app = RadrootsApp::new(Box::new(
MockBackend::new(
Ok(fixture_ready_state()),
@@ -1483,24 +1792,37 @@ mod tests {
enabled: true,
pending: false,
},
- vec![Ok(HomeActionResult::RevealSecretKey {
- nsec: FIXTURE_ALICE.nsec.into(),
- })],
- ),
+ vec![],
+ )
+ .with_secret_key_backup_request(vec![Ok(
+ HomeActionResult::RevealEncryptedSecretKey {
+ ncryptsec: encrypted_secret_key.clone(),
+ },
+ )]),
));
- app.request_home_action(HomeActionKind::BackupSecretKey);
+ app.pending_secret_key_backup_entry = true;
+ app.secret_key_backup_password_input = Zeroizing::new(FIXTURE_BACKUP_PASSWORD.into());
+ app.secret_key_backup_password_confirm_input =
+ Zeroizing::new(FIXTURE_BACKUP_PASSWORD.into());
+ app.request_secret_key_backup_action();
assert!(matches!(app.screen, AppScreen::Home { .. }));
assert_eq!(app.pending_home_confirmation, None);
- assert_eq!(
- app.revealed_secret_key.as_ref().map(|value| value.as_str()),
- Some(FIXTURE_ALICE.nsec)
- );
+ assert_eq!(app.pending_secret_key_backup_entry, false);
+ let Some(RevealedSecretMaterial::EncryptedSecretKey(value)) =
+ app.revealed_secret_material.as_ref()
+ else {
+ panic!("expected encrypted secret backup");
+ };
+ assert_eq!(value.as_str(), encrypted_secret_key);
}
#[test]
- fn deferred_backup_home_action_reveals_secret_key_after_poll() {
+ fn deferred_encrypted_backup_home_action_reveals_secret_key_after_poll() {
+ let encrypted_secret_key =
+ fixture_identity_ncryptsec(&FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD)
+ .expect("fixture encrypted secret key");
let mut app = RadrootsApp::new(Box::new(
MockBackend::new(
Ok(fixture_ready_state()),
@@ -1519,22 +1841,121 @@ mod tests {
enabled: true,
pending: true,
},
- vec![Ok(HomeActionResult::None)],
+ vec![],
)
- .with_home_action_poll(vec![Ok(Some(HomeActionResult::RevealSecretKey {
- nsec: FIXTURE_ALICE.nsec.into(),
- }))]),
+ .with_secret_key_backup_request(vec![Ok(HomeActionResult::None)])
+ .with_home_action_poll(vec![Ok(Some(
+ HomeActionResult::RevealEncryptedSecretKey {
+ ncryptsec: encrypted_secret_key.clone(),
+ },
+ ))]),
));
- app.request_home_action(HomeActionKind::BackupSecretKey);
- assert_eq!(app.revealed_secret_key, None);
+ app.pending_secret_key_backup_entry = true;
+ app.secret_key_backup_password_input = Zeroizing::new(FIXTURE_BACKUP_PASSWORD.into());
+ app.secret_key_backup_password_confirm_input =
+ Zeroizing::new(FIXTURE_BACKUP_PASSWORD.into());
+ app.request_secret_key_backup_action();
+ assert_eq!(app.revealed_secret_material, None);
app.sync_backend();
- assert_eq!(
- app.revealed_secret_key.as_ref().map(|value| value.as_str()),
- Some(FIXTURE_ALICE.nsec)
- );
+ let Some(RevealedSecretMaterial::EncryptedSecretKey(value)) =
+ app.revealed_secret_material.as_ref()
+ else {
+ panic!("expected encrypted secret backup");
+ };
+ assert_eq!(value.as_str(), encrypted_secret_key);
+ }
+
+ #[test]
+ fn raw_secret_reveal_home_action_uses_advanced_path() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(fixture_ready_state()),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_home_action(
+ HomeActionState {
+ kind: HomeActionKind::RevealRawSecretKey,
+ label: "Reveal Raw Secret Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ vec![Ok(HomeActionResult::RevealRawSecretKey {
+ nsec: FIXTURE_ALICE.nsec.into(),
+ })],
+ ),
+ ));
+
+ app.pending_home_confirmation = Some(HomeActionKind::RevealRawSecretKey);
+ app.request_home_action(HomeActionKind::RevealRawSecretKey);
+
+ let Some(RevealedSecretMaterial::RawSecretKey { nsec, .. }) =
+ app.revealed_secret_material.as_ref()
+ else {
+ panic!("expected raw secret reveal");
+ };
+ assert_eq!(nsec.as_str(), FIXTURE_ALICE.nsec);
+ }
+
+ #[test]
+ fn raw_secret_reveal_expires_after_timeout() {
+ let mut app = RadrootsApp::new(Box::new(MockBackend::new(
+ Ok(fixture_ready_state()),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )));
+ app.revealed_secret_material = Some(RevealedSecretMaterial::RawSecretKey {
+ nsec: Zeroizing::new(FIXTURE_ALICE.nsec.into()),
+ revealed_at: Instant::now() - RAW_SECRET_REVEAL_TIMEOUT - Duration::from_secs(1),
+ });
+
+ app.sync_revealed_secret_material_lifetime();
+
+ assert_eq!(app.revealed_secret_material, None);
+ }
+
+ #[test]
+ fn raw_secret_reveal_clears_when_app_loses_focus() {
+ let mut app = RadrootsApp::new(Box::new(MockBackend::new(
+ Ok(fixture_ready_state()),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )));
+ app.revealed_secret_material = Some(RevealedSecretMaterial::RawSecretKey {
+ nsec: Zeroizing::new(FIXTURE_ALICE.nsec.into()),
+ revealed_at: Instant::now(),
+ });
+
+ let ctx = egui::Context::default();
+ ctx.input_mut(|input| {
+ input
+ .raw
+ .viewports
+ .entry(egui::ViewportId::ROOT)
+ .or_default()
+ .focused = Some(false);
+ });
+ app.clear_raw_secret_when_app_unfocused(&ctx);
+
+ assert_eq!(app.revealed_secret_material, None);
}
#[test]
diff --git a/crates/core/src/secret_keys.rs b/crates/core/src/secret_keys.rs
@@ -0,0 +1,58 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum RadrootsSecretImportMode {
+ #[default]
+ EncryptedSecretKey,
+ RawSecretKey,
+}
+
+impl RadrootsSecretImportMode {
+ pub fn helper_text(self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey => {
+ "Import an existing local identity by entering its encrypted secret key and password."
+ }
+ Self::RawSecretKey => {
+ "Advanced: import an existing local identity by entering its raw nsec secret key."
+ }
+ }
+ }
+
+ pub fn hint_text(self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey => "ncryptsec1...",
+ Self::RawSecretKey => "nsec1...",
+ }
+ }
+
+ pub fn mode_label(self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey => "Encrypted Secret Key",
+ Self::RawSecretKey => "Raw Secret Key",
+ }
+ }
+
+ pub fn switch_label(self) -> &'static str {
+ match self {
+ Self::EncryptedSecretKey => "Use Raw Secret Key Instead",
+ Self::RawSecretKey => "Use Encrypted Secret Key Instead",
+ }
+ }
+
+ pub fn requires_password(self) -> bool {
+ matches!(self, Self::EncryptedSecretKey)
+ }
+
+ pub fn toggle(self) -> Self {
+ match self {
+ Self::EncryptedSecretKey => Self::RawSecretKey,
+ Self::RawSecretKey => Self::EncryptedSecretKey,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSecretImportRequest {
+ pub mode: RadrootsSecretImportMode,
+ pub secret_text: String,
+ pub password: Option<String>,
+}
diff --git a/crates/test-support/src/lib.rs b/crates/test-support/src/lib.rs
@@ -1,6 +1,9 @@
#![forbid(unsafe_code)]
-use radroots_identity::RadrootsIdentity;
+use radroots_identity::{
+ RadrootsIdentity, RadrootsIdentityEncryptedSecretKeyOptions,
+ RadrootsIdentityEncryptedSecretKeySecurity,
+};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RadrootsAppApprovedFixtureIdentity {
@@ -60,6 +63,7 @@ pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com";
pub const APP_PRIMARY_URL: &str = "https://app.example.com";
pub const API_PRIMARY_URL: &str = "https://api.example.com";
pub const CDN_PRIMARY_URL: &str = "https://cdn.example.com";
+pub const FIXTURE_BACKUP_PASSWORD: &str = "fixture-backup-password";
pub fn fixture_identity(
fixture: &RadrootsAppApprovedFixtureIdentity,
@@ -67,6 +71,19 @@ pub fn fixture_identity(
RadrootsIdentity::from_secret_key_str(fixture.secret_key_hex)
}
+pub fn fixture_identity_ncryptsec(
+ fixture: &RadrootsAppApprovedFixtureIdentity,
+ password: &str,
+) -> Result<String, radroots_identity::IdentityError> {
+ fixture_identity(fixture)?.encrypt_secret_key_ncryptsec_with_options(
+ password,
+ RadrootsIdentityEncryptedSecretKeyOptions {
+ log_n: 10,
+ key_security: RadrootsIdentityEncryptedSecretKeySecurity::Weak,
+ },
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs
@@ -588,6 +588,7 @@ impl RadrootsAppBackend for WebBackend {
Ok(HomeActionResult::IdentityState(self.disconnect_signer()))
}
HomeActionKind::BackupSecretKey
+ | HomeActionKind::RevealRawSecretKey
| HomeActionKind::RemoveLocalKey
| HomeActionKind::ResetDevice => Ok(HomeActionResult::None),
}