1
0
forked from GRIN/grim

goblin: delete identity (double-gated) and unified import (Build 146)

DELETE: each held identity row gains a trash affordance. Step 1 is a danger
card naming the identity, stating the removal is PERMANENT and prominently
reminding the user to back it up first (unrecoverable without its nsec or
.backup); step 2 is the existing wallet-password modal, which executes
Wallet::delete_nostr_identity. Delete drops the entry from the held-identity
index, removes its on-disk encrypted file, and rebuilds the service without it,
so its pubkey leaves the multi-pubkey gift-wrap subscription and its key leaves
the unlocked in-memory set. The shared balance and all other identities are
untouched. Edge cases: the LAST identity can never be deleted (the trash only
renders with more than one held); deleting the ACTIVE identity promotes a
survivor to active first; deleting the legacy primary (identity.json) promotes
a survivor INTO identity.json so init_nostr's fallback and an older build's
rollback anchor still resolve, never leaving a hole that would mint a fresh key.

IMPORT: the add panel's "Import nsec" is now just "Import" and offers both a
.backup file picker and a paste-an-nsec field. The .backup path reuses the same
from_encrypted_backup + re-encrypt-under-this-wallet's-password flow as the
existing identity import (name and history restored), through the same NIP-49
encrypted store; the pasted-nsec path is unchanged. New strings in all six
locales; drift green.
This commit is contained in:
2ro
2026-07-05 21:50:42 -04:00
parent 2acfd46784
commit 2592eadc9f
8 changed files with 410 additions and 30 deletions
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "Identität hinzufügen"
add_title: "Eine Identität hinzufügen"
generate: "Neu erzeugen"
import: "Nsec importieren"
import: "Importieren"
nsec_hint: "Nsec einfügen"
choose_backup: "Eine .backup-Datei wählen"
backup_selected: "Backup-Datei ausgewählt"
delete_short: "Identität löschen"
delete_title: "%{name} löschen?"
delete_blurb: "Dies entfernt die Identität dauerhaft aus dieser Wallet. Bereits erhaltenes Geld bleibt in deinem Guthaben."
delete_backup_note: "Sichere sie zuerst — ohne ihren nsec oder ihre .backup-Datei ist sie nicht wiederherstellbar."
delete_confirm: "Löschen"
add_confirm: "Hinzufügen"
pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren."
working: "Arbeite…"
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "Add identity"
add_title: "Add an identity"
generate: "Generate new"
import: "Import nsec"
import: "Import"
nsec_hint: "Paste an nsec"
choose_backup: "Choose a .backup file"
backup_selected: "Backup file selected"
delete_short: "Delete identity"
delete_title: "Delete %{name}?"
delete_blurb: "This permanently removes the identity from this wallet. Money already received stays in your balance."
delete_backup_note: "Back it up first — without its nsec or .backup file it cannot be recovered."
delete_confirm: "Delete"
add_confirm: "Add"
pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue."
working: "Working…"
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "Ajouter une identité"
add_title: "Ajouter une identité"
generate: "En générer une"
import: "Importer un nsec"
import: "Importer"
nsec_hint: "Coller un nsec"
choose_backup: "Choisir un fichier .backup"
backup_selected: "Fichier de sauvegarde sélectionné"
delete_short: "Supprimer l'identité"
delete_title: "Supprimer %{name} ?"
delete_blurb: "Cela supprime définitivement l'identité de ce portefeuille. L'argent déjà reçu reste dans votre solde."
delete_backup_note: "Sauvegardez-la d'abord — sans son nsec ou son fichier .backup, elle est irrécupérable."
delete_confirm: "Supprimer"
add_confirm: "Ajouter"
pass_prompt: "Le mot de passe de votre portefeuille chiffre et déverrouille chaque identité. Saisissez-le pour continuer."
working: "En cours…"
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "Добавить личность"
add_title: "Добавить личность"
generate: "Создать новую"
import: "Импорт nsec"
import: "Импорт"
nsec_hint: "Вставьте nsec"
choose_backup: "Выбрать файл .backup"
backup_selected: "Файл резервной копии выбран"
delete_short: "Удалить личность"
delete_title: "Удалить %{name}?"
delete_blurb: "Это навсегда удалит личность из этого кошелька. Уже полученные деньги останутся на балансе."
delete_backup_note: "Сначала сделайте резервную копию — без её nsec или файла .backup её невозможно восстановить."
delete_confirm: "Удалить"
add_confirm: "Добавить"
pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить."
working: "Обработка…"
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "Kimlik ekle"
add_title: "Bir kimlik ekle"
generate: "Yeni oluştur"
import: "Nsec içe aktar"
import: "İçe aktar"
nsec_hint: "Bir nsec yapıştır"
choose_backup: "Bir .backup dosyası seç"
backup_selected: "Yedek dosyası seçildi"
delete_short: "Kimliği sil"
delete_title: "%{name} silinsin mi?"
delete_blurb: "Bu, kimliği bu cüzdandan kalıcı olarak kaldırır. Zaten alınan para bakiyende kalır."
delete_backup_note: "Önce yedeğini al — nseci veya .backup dosyası olmadan geri getirilemez."
delete_confirm: "Sil"
add_confirm: "Ekle"
pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir."
working: "Çalışıyor…"
+8 -1
View File
@@ -686,8 +686,15 @@ goblin:
add: "添加身份"
add_title: "添加一个身份"
generate: "新建"
import: "导入 nsec"
import: "导入"
nsec_hint: "粘贴 nsec"
choose_backup: "选择 .backup 文件"
backup_selected: "已选择备份文件"
delete_short: "删除身份"
delete_title: "删除 %{name}"
delete_blurb: "这将从此钱包中永久移除该身份。已收到的钱仍保留在你的余额中。"
delete_backup_note: "请先备份——没有它的 nsec 或 .backup 文件将无法恢复。"
delete_confirm: "删除"
add_confirm: "添加"
pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。"
working: "处理中…"
+220 -22
View File
@@ -321,11 +321,16 @@ impl Default for ImportState {
/// mirroring the wallet-open password modal.
const IDENTITY_PASS_MODAL: &str = "goblin_identity_pass_modal";
/// A pending add the wallet-password modal is gating: generate a fresh key
/// (`None`) or import an nsec (`Some`). Switching no longer uses the modal (it is
/// instant and local), so this is the only action the modal encrypts for.
/// A password-gated identity action the modal executes. Switching no longer uses
/// the modal (it is instant and local); only these need the wallet password.
#[derive(Clone)]
struct PendingAdd(Option<String>);
enum PendingPassAction {
/// Add a held identity: generate a fresh key (`None`) or import an nsec / a
/// sealed .backup blob (`Some`).
Add(Option<String>),
/// Permanently delete this held identity (pubkey hex).
Delete(String),
}
/// Identity switcher page state: the add-identity sub-form and the wallet-password
/// modal buffer (the encrypt step for ADDING an identity — switching is instant
@@ -335,12 +340,19 @@ struct PendingAdd(Option<String>);
struct IdentitySwitchState {
/// Add-identity sub-form is open.
adding: bool,
/// Add mode is import-an-nsec (else generate a fresh one).
/// Add mode is import (else generate a fresh one). Import offers a .backup
/// file picker AND a paste-an-nsec field.
import: bool,
/// Pasted nsec when importing.
nsec: String,
/// The add the password modal is currently gating.
pending: Option<PendingAdd>,
/// Contents of a selected .backup file (import path); wins over `nsec`.
backup_input: String,
/// A native .backup file pick is in flight (Android returns it asynchronously).
picking: bool,
/// A held identity awaiting the step-1 delete confirmation (pubkey hex).
confirm_delete: Option<String>,
/// The password-gated action the modal is currently gating.
pending: Option<PendingPassAction>,
/// Password typed into the modal; cleared as soon as it is consumed.
pass: String,
/// The modal password didn't unlock — show the wrong-password line.
@@ -4858,6 +4870,8 @@ impl GoblinWalletView {
self.identity_switch.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
self.identity_switch.backup_input.clear();
self.identity_switch.confirm_delete = None;
}
Err(e) => {
self.identity_switch.error = e;
@@ -4899,13 +4913,18 @@ impl GoblinWalletView {
w::kicker(ui, &t!("goblin.identities.held"));
ui.add_space(8.0);
let busy = self.identity_switch.busy;
// A wallet must keep at least one identity, so delete is only offered
// when more than one is held.
let can_delete = identities.len() > 1 && !busy;
let mut switch_to: Option<String> = None;
let mut delete_target: Option<String> = None;
for id in &identities {
// A claimed name (no leading @, per the project convention), else
// the truncated npub. Never a placeholder word.
let short = data::short_npub(&id.pubkey_hex);
let title = id.name.clone().unwrap_or_else(|| short.clone());
let named = id.name.is_some();
let mut delete_this = false;
let row = w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
@@ -4941,24 +4960,50 @@ impl GoblinWalletView {
.color(t.surface_text_dim),
);
}
// A trash affordance to the LEFT of that indicator; its
// own tap target so it doesn't trigger the row switch.
if can_delete {
ui.add_space(8.0);
let (r, resp) =
ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::TRASH,
FontId::new(16.0, fonts::regular()),
t.neg,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
delete_this = true;
}
}
});
})
.response
.rect
});
// Tap anywhere else on a non-active row = switch (skip when the
// trash was tapped, whose rect overlaps the row interact).
if !id.active && !busy {
let hit = ui.interact(
row,
egui::Id::new(("id_switch", id.pubkey_hex.as_str())),
Sense::click(),
);
if hit
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
if !delete_this
&& hit
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
switch_to = Some(id.pubkey_hex.clone());
}
}
if delete_this {
delete_target = Some(id.pubkey_hex.clone());
}
ui.add_space(6.0);
}
@@ -4970,6 +5015,91 @@ impl GoblinWalletView {
self.identity_switch.error = e;
}
}
// Tapping the trash opens the step-1 danger confirmation.
if let Some(target) = delete_target {
self.identity_switch.error.clear();
self.identity_switch.confirm_delete = Some(target);
}
// Step-1 delete confirmation: a clear danger card naming the
// identity, stating the delete is PERMANENT, and reminding the user
// to back it up first. Continuing opens the wallet-password modal
// (step 2), which actually executes the delete.
if let Some(target) = self.identity_switch.confirm_delete.clone() {
let display = identities
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| {
i.name
.clone()
.unwrap_or_else(|| data::short_npub(&i.pubkey_hex))
})
.unwrap_or_else(|| data::short_npub(&target));
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.identities.delete_title", name => display))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_backup_note"))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
.clicked()
{
self.identity_switch.confirm_delete = None;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.delete_confirm"),
t.neg,
)
.clicked()
{
// Step 2: the wallet-password modal executes it.
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending =
Some(PendingPassAction::Delete(target.clone()));
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.delete_short"))
.show();
}
},
);
});
});
}
ui.add_space(8.0);
@@ -5041,6 +5171,55 @@ impl GoblinWalletView {
});
if self.identity_switch.import {
ui.add_space(8.0);
// (a) Select a .backup file. Desktop returns the path now;
// Android returns it asynchronously (poll picked_file()).
if self.identity_switch.picking {
if let Some(path) = cb.picked_file() {
self.identity_switch.picking = false;
if !path.is_empty() {
match std::fs::read_to_string(&path) {
Ok(c) => {
self.identity_switch.backup_input =
c.trim().to_string()
}
Err(_) => {
self.identity_switch.error =
t!("goblin.settings.backup_read_failed")
.to_string()
}
}
}
} else {
ui.ctx().request_repaint();
}
}
let file_label = if self.identity_switch.backup_input.is_empty() {
t!("goblin.identities.choose_backup").to_string()
} else {
t!("goblin.identities.backup_selected").to_string()
};
if w::big_action_on_card(ui, &file_label).clicked() {
self.identity_switch.error.clear();
match cb.pick_file() {
Some(path) if !path.is_empty() => {
match std::fs::read_to_string(&path) {
Ok(c) => {
self.identity_switch.backup_input =
c.trim().to_string()
}
Err(_) => {
self.identity_switch.error =
t!("goblin.settings.backup_read_failed")
.to_string()
}
}
}
Some(_) => self.identity_switch.picking = true,
None => {}
}
}
ui.add_space(8.0);
// (b) Or paste an nsec.
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("identity_add_nsec"))
.focus(false)
@@ -5053,11 +5232,11 @@ impl GoblinWalletView {
}
ui.add_space(10.0);
let import = self.identity_switch.import;
// Generate is always ready; import needs a plausible nsec. The
// password is entered in the modal (opened on Add), not here.
let armed = (!import
|| self.identity_switch.nsec.trim().starts_with("nsec1"))
&& !self.identity_switch.busy;
// Generate is always ready; import needs either a selected
// .backup or a pasted nsec. The password is entered in the modal.
let has_import = !self.identity_switch.backup_input.trim().is_empty()
|| self.identity_switch.nsec.trim().starts_with("nsec1");
let armed = (!import || has_import) && !self.identity_switch.busy;
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
@@ -5072,6 +5251,7 @@ impl GoblinWalletView {
self.identity_switch.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
self.identity_switch.backup_input.clear();
self.identity_switch.error.clear();
}
},
@@ -5093,9 +5273,21 @@ impl GoblinWalletView {
{
// Open the password modal to encrypt+store the
// new identity. It is added WITHOUT switching;
// the user activates it later via tap → modal.
let import_nsec = if import {
Some(self.identity_switch.nsec.trim().to_string())
// the user activates it later by tapping it. The
// import blob is the selected .backup if any,
// else the pasted nsec.
let import_blob = if import {
let b = self.identity_switch.backup_input.trim();
if b.is_empty() {
Some(
self.identity_switch
.nsec
.trim()
.to_string(),
)
} else {
Some(b.to_string())
}
} else {
None
};
@@ -5103,7 +5295,7 @@ impl GoblinWalletView {
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending =
Some(PendingAdd(import_nsec));
Some(PendingPassAction::Add(import_blob));
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.add_title"))
@@ -5211,16 +5403,22 @@ impl GoblinWalletView {
if go && !self.identity_switch.pass.is_empty() {
if !wallet.verify_nostr_password(&self.identity_switch.pass) {
self.identity_switch.wrong_pass = true;
} else if let Some(PendingAdd(import_nsec)) = self.identity_switch.pending.take() {
} else if let Some(action) = self.identity_switch.pending.take() {
let password = std::mem::take(&mut self.identity_switch.pass);
self.identity_switch.wrong_pass = false;
self.identity_switch.error.clear();
self.identity_switch.busy = true;
let slot = self.identity_switch.result.clone();
let w = wallet.clone();
// Add only — never auto-switch into the new identity.
std::thread::spawn(move || {
let r = w.add_nostr_identity(import_nsec, password);
let r = match action {
// Add only — never auto-switch into the new identity.
PendingPassAction::Add(import) => w.add_nostr_identity(import, password),
// Delete returns the surviving active npub for the result slot.
PendingPassAction::Delete(hex) => w
.delete_nostr_identity(hex, password)
.map(|_| String::new()),
};
*slot.lock().unwrap() = Some(r);
});
Modal::close();
+142 -2
View File
@@ -843,9 +843,34 @@ impl Wallet {
.read()
.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
// The import blob may be a bare nsec OR a sealed GOBLIN-*.backup file
// (reusing the same open path as `import_nostr_identity`). Either becomes a
// held identity re-encrypted under THIS wallet's password.
let (identity, _keys) = match import {
Some(nsec) => NostrIdentity::create_imported(nsec.trim(), &password)
.map_err(|_| "Invalid nsec".to_string())?,
Some(blob) => {
let blob = blob.trim();
if NostrIdentity::is_encrypted_backup(blob) {
// A .backup: open it (same wallet password, since a backup made
// by this wallet is sealed under it), then re-encrypt under the
// wallet password and restore its name/history.
let (backup, keys) = NostrIdentity::from_encrypted_backup(blob, &password)
.map_err(|_| {
"Couldn't open the backup — it may have been made under a \
different wallet password."
.to_string()
})?;
let mut ident =
NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
.map_err(|e| format!("re-encryption failed: {e}"))?;
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
(ident, keys)
} else {
NostrIdentity::create_imported(blob, &password)
.map_err(|_| "Invalid nsec".to_string())?
}
}
None => NostrIdentity::create_random(&password)
.map_err(|e| format!("key generation failed: {e}"))?,
};
@@ -913,6 +938,121 @@ impl Wallet {
Ok(new_npub)
}
/// PERMANENTLY delete a held identity: drop it from the held-identity index,
/// delete its on-disk encrypted file, and rebuild the running service without
/// it (so its pubkey leaves the multi-pubkey gift-wrap subscription and its key
/// leaves the in-memory set). The one shared grin balance and every other
/// identity are untouched. Unrecoverable unless its nsec/.backup was saved.
///
/// Edge cases:
/// - Refuses to delete the LAST identity (a wallet must keep >= 1).
/// - Deleting the ACTIVE identity first switches active to a survivor.
/// - Deleting the legacy primary (the `identity.json` entry, which `init_nostr`
/// falls back to and an older build opens) PROMOTES a survivor into
/// `identity.json` so that fallback still resolves — never leaving a hole
/// that would spawn a fresh identity on next open.
pub fn delete_nostr_identity(
&self,
target_hex: String,
password: String,
) -> Result<(), String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
if !self.verify_nostr_password(&password) {
return Err("Wrong password".to_string());
}
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
let mut index = HeldIdentities::load(&nostr_dir)
.ok_or_else(|| "identity index unavailable".to_string())?;
// (a) Never delete the only identity.
if index.len() <= 1 {
return Err("You must keep at least one identity".to_string());
}
let entry = index
.entry(&target_hex)
.cloned()
.ok_or_else(|| "identity not held by this wallet".to_string())?;
// A survivor to take over the active pointer / primary role.
let survivor = index
.identities
.iter()
.find(|e| e.pubkey != target_hex)
.map(|e| e.pubkey.clone())
.ok_or_else(|| "no other identity".to_string())?;
// (c) Deleting the legacy primary: promote the survivor into identity.json
// so init_nostr's fallback/rollback anchor still resolves. Otherwise just
// delete the target's own file.
if entry.path == "identity.json" {
let survivor_entry = index
.entry(&survivor)
.cloned()
.ok_or_else(|| "survivor missing".to_string())?;
let survivor_id = survivor_entry
.load(&nostr_dir)
.ok_or_else(|| "survivor unreadable".to_string())?;
// Overwrite identity.json with the survivor (its old content = the
// deleted key is gone), and repoint the survivor entry to identity.json.
survivor_id
.save(&nostr_dir)
.map_err(|e| format!("promote failed: {e}"))?;
if survivor_entry.path != "identity.json" {
let old_abs = survivor_entry.abs_path(&nostr_dir);
for e in index.identities.iter_mut() {
if e.pubkey == survivor {
e.path = "identity.json".to_string();
}
}
let _ = std::fs::remove_file(old_abs);
}
} else {
let _ = std::fs::remove_file(entry.abs_path(&nostr_dir));
}
// Remove the target from the index (entries + order); repoint active if it
// was the deleted one.
index.identities.retain(|e| e.pubkey != target_hex);
index.order.retain(|h| h != &target_hex);
if index.active == target_hex {
index.active = survivor.clone();
}
index
.save(&nostr_dir)
.map_err(|e| format!("index save failed: {e}"))?;
// (b) If the deleted identity was active, the survivor becomes active.
let active_hex = if svc.public_key().to_hex() == target_hex {
survivor
} else {
svc.public_key().to_hex()
};
// Rebuild the service WITHOUT the deleted identity: unlock_all_identities now
// excludes it, so it leaves both the subscription and the in-memory key set.
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
let store = NostrStore::new(config.get_nostr_db_path());
let (recv, _) = self
.unlock_all_identities(&nostr_dir, &password)
.ok_or_else(|| "identity unlock failed".to_string())?;
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
}
new_svc.start(self.clone());
info!("nostr: deleted held identity {target_hex}");
Ok(())
}
/// Get keychain mask [`SecretKey`].
pub fn keychain_mask(&self) -> Option<SecretKey> {
let r_key = self.keychain_mask.read();