From 4337580806eca6d271b46798bb7482356ee4922b Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:47:03 -0400 Subject: [PATCH] goblin: full wallet backup (seed + all identities) with restore The .backup file now seals the money seed AND every held identity in one encrypted, versioned envelope, and the caption's promise ("Contains your wallet and all identities.") is finally true. Format (nostr/identity.rs): a JSON envelope tagged goblin_backup:2. The seed phrase is sealed with seal_secret_text() and each identity with the existing per-identity to_encrypted_backup() (so every element is itself a valid v1 identity envelope). Both layers reuse the in-tree NIP-49 ncryptsec + NIP-44 primitives under one password: no new crypto, no new dependency. The plaintext seed is assembled and sealed in memory (the zeroizing recovery string drops at scope end) and never written to disk. Old v1 single-identity backups keep restoring unchanged; is_full_backup() is checked before the v1 path so the two never collide. Build (wallet.rs): create_full_backup() gathers the seed via the existing recovery API (which proves the wallet password) plus every unlocked held identity. restore_full_backup_identities() reinstates them into a freshly created wallet, re-encrypting each under the new wallet password, making the backup's active identity active, and reusing the held-index/service-rebuild plumbing. The identity-only import paths now reject a full backup with a clear pointer to wallet creation. Restore (onboarding.rs): the restore-from-seed step gains a .backup picker; unlocking it decrypts the seed into the standard 24-word creation grid and stashes the identities, which a worker reinstates once the wallet opens. Copy: backup caption, title, blurb, and saved-sub updated across all nine locales; two new restore strings added in all nine. Tests: full_backup_roundtrips_seed_identities_and_active, old_single_identity_backup_is_not_a_full_backup, seal_secret_text_roundtrips_and_is_opaque; i18n drift + news tests green. --- locales/de.yml | 11 +- locales/en.yml | 11 +- locales/es.yml | 11 +- locales/fr.yml | 11 +- locales/ja.yml | 11 +- locales/ko.yml | 11 +- locales/ru.yml | 11 +- locales/tr.yml | 11 +- locales/zh-CN.yml | 11 +- src/gui/views/goblin/mod.rs | 2 +- src/gui/views/goblin/onboarding.rs | 197 +++++++++++++++++++++++++++ src/nostr/identity.rs | 207 +++++++++++++++++++++++++++++ src/nostr/mod.rs | 4 +- src/wallet/wallet.rs | 151 +++++++++++++++++++-- 14 files changed, 611 insertions(+), 49 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index b033ad81..d9552d34 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Eine .backup-Datei wählen" backup_read_failed: "Datei konnte nicht gelesen werden." backup_saved: "Sicherung gespeichert" - backup_saved_sub: "Bewahre die .backup-Datei sicher auf — wer sie UND dein Passwort hat, kann deine Identität wiederherstellen." - backup_file_title: "Identität sichern" - backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit Benutzername und Schlüssel. Gib dein Wallet-Passwort ein, um sie zu versiegeln." + backup_saved_sub: "Bewahre die .backup-Datei sicher auf. Wer sie UND dein Passwort hat, kann dein gesamtes Wallet wiederherstellen." + backup_file_title: "Wallet sichern" + backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit deinem Wallet-Seed und allen Identitäten. Gib dein Wallet-Passwort ein, um sie zu versiegeln." backup_write_failed: "Datei konnte nicht gespeichert werden." create_backup: "Sicherung erstellen" registered: "%{name} registriert" @@ -633,7 +633,7 @@ goblin: show_qr: "QR anzeigen" hide_qr: "QR ausblenden" nostr_section: "Erweiterte Nostr-Einstellungen" - backup_caption: "Enthält den Schlüssel und Benutzernamen deiner aktuellen Identität." + backup_caption: "Enthält dein Wallet und alle Identitäten." danger_zone: "Gefahrenzone" delete_warning: "Dies löscht deine Wiederherstellungsphrase und jede Identität auf diesem Gerät. Alles, was du nicht gesichert hast, ist unwiederbringlich verloren." download_backup: "Backup herunterladen" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Wallet wiederherstellen" wrote_them_down: "Ich habe sie aufgeschrieben" fill_every_word: "Fülle jedes Wort aus — tippe ein Wort an, um es zu bearbeiten, oder füge die Phrase ein." + unlock_backup: "Backup entsperren" + backup_unlocked: "Backup entsperrt. Deine Identitäten werden beim Öffnen des Wallets wiederhergestellt." confirm: kicker: "SCHRITT 2 VON 3 · WALLET" title: "Jetzt beweise es" @@ -856,6 +858,7 @@ goblin: connected_nym: "über Tor verbunden" connecting_nym: "verbinde über Tor…" fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren." + restoring: "Deine Identitäten werden wiederhergestellt…" clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht." pick_username: "Benutzernamen wählen — optional" username_blurb: "Freunde zahlen an deinen Namen statt an einen langen Schlüssel. Optional — jederzeit beanspruchbar." diff --git a/locales/en.yml b/locales/en.yml index 182ca5d8..9e8af8c8 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Choose a .backup file" backup_read_failed: "Couldn't read that file." backup_saved: "Backup saved" - backup_saved_sub: "Keep the .backup file safe — anyone with it AND your password can restore your identity." - backup_file_title: "Back up identity" - backup_file_blurb: "Creates one encrypted .backup file with your username and key. Enter your wallet password to seal it." + backup_saved_sub: "Keep the .backup file safe. Anyone with it AND your password can restore your whole wallet." + backup_file_title: "Back up wallet" + backup_file_blurb: "Creates one encrypted .backup file with your wallet seed and every identity. Enter your wallet password to seal it." backup_write_failed: "Couldn't save the file." create_backup: "Create backup" registered: "Registered %{name}" @@ -633,7 +633,7 @@ goblin: show_qr: "Show QR" hide_qr: "Hide QR" nostr_section: "Advanced nostr settings" - backup_caption: "Contains your current identity's key and username." + backup_caption: "Contains your wallet and all identities." danger_zone: "Danger zone" delete_warning: "This erases your recovery phrase and every identity on this device. Anything you haven't backed up is gone for good." download_backup: "Download backup" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Restore wallet" wrote_them_down: "I wrote them down" fill_every_word: "Fill every word — tap a word to edit it, or paste the phrase." + unlock_backup: "Unlock backup" + backup_unlocked: "Backup unlocked. Your identities restore when the wallet opens." confirm: kicker: "STEP 2 OF 3 · WALLET" title: "Now prove it" @@ -856,6 +858,7 @@ goblin: connected_nym: "connected over Tor" connecting_nym: "connecting over Tor…" fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds." + restoring: "Restoring your identities…" clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face." pick_username: "Pick a username — optional" username_blurb: "Friends pay your name instead of a long key. Optional — claim one any time." diff --git a/locales/es.yml b/locales/es.yml index 8b4f8d24..da94117a 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Elegir un archivo .backup" backup_read_failed: "No se pudo leer ese archivo." backup_saved: "Respaldo guardado" - backup_saved_sub: "Mantén el archivo .backup a salvo — cualquiera que lo tenga JUNTO CON tu contraseña puede restaurar tu identidad." - backup_file_title: "Respaldar identidad" - backup_file_blurb: "Crea un archivo .backup cifrado con tu nombre de usuario y clave. Ingresa tu contraseña de billetera para sellarlo." + backup_saved_sub: "Mantén el archivo .backup a salvo. Cualquiera que lo tenga JUNTO CON tu contraseña puede restaurar toda tu billetera." + backup_file_title: "Respaldar billetera" + backup_file_blurb: "Crea un archivo .backup cifrado con la semilla de tu billetera y todas tus identidades. Ingresa tu contraseña de billetera para sellarlo." backup_write_failed: "No se pudo guardar el archivo." create_backup: "Crear respaldo" registered: "%{name} registrado" @@ -633,7 +633,7 @@ goblin: show_qr: "Mostrar QR" hide_qr: "Ocultar QR" nostr_section: "Ajustes avanzados de nostr" - backup_caption: "Contiene la clave y el nombre de usuario de tu identidad actual." + backup_caption: "Contiene tu billetera y todas tus identidades." danger_zone: "Zona de peligro" delete_warning: "Esto borra tu frase de recuperación y todas las identidades de este dispositivo. Todo lo que no hayas respaldado se perderá para siempre." download_backup: "Descargar copia de seguridad" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Restaurar billetera" wrote_them_down: "Ya las anoté" fill_every_word: "Completa cada palabra — toca una palabra para editarla, o pega la frase." + unlock_backup: "Desbloquear respaldo" + backup_unlocked: "Respaldo desbloqueado. Tus identidades se restauran cuando se abre la billetera." confirm: kicker: "PASO 2 DE 3 · BILLETERA" title: "Ahora demuéstralo" @@ -856,6 +858,7 @@ goblin: connected_nym: "conectado por Tor" connecting_nym: "conectando por Tor…" fresh_key_blurb: "Una clave de pago que no forma parte de tu frase de recuperación — rótala cuando quieras para mantener la privacidad, sin tocar tus fondos." + restoring: "Restaurando tus identidades…" clean_slate_blurb: "¿Quieres empezar de cero? Cambia a una clave totalmente nueva cuando quieras — el nuevo tú no está vinculado al anterior. Misma billetera, cara nueva." pick_username: "Elige un nombre de usuario — opcional" username_blurb: "Tus amigos pagan a tu nombre en lugar de a una clave larga. Opcional — reclama uno cuando quieras." diff --git a/locales/fr.yml b/locales/fr.yml index 6206a709..2545a4d2 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Choisir un fichier .backup" backup_read_failed: "Impossible de lire ce fichier." backup_saved: "Sauvegarde enregistrée" - backup_saved_sub: "Conservez le fichier .backup en lieu sûr — quiconque l'a AVEC votre mot de passe peut restaurer votre identité." - backup_file_title: "Sauvegarder l'identité" - backup_file_blurb: "Crée un fichier .backup chiffré avec votre nom d'utilisateur et votre clé. Saisissez le mot de passe du portefeuille pour le sceller." + backup_saved_sub: "Conservez le fichier .backup en lieu sûr. Quiconque l'a AVEC votre mot de passe peut restaurer tout votre portefeuille." + backup_file_title: "Sauvegarder le portefeuille" + backup_file_blurb: "Crée un fichier .backup chiffré avec la graine de votre portefeuille et toutes vos identités. Saisissez le mot de passe du portefeuille pour le sceller." backup_write_failed: "Impossible d'enregistrer le fichier." create_backup: "Créer la sauvegarde" registered: "%{name} enregistré" @@ -633,7 +633,7 @@ goblin: show_qr: "Afficher le QR" hide_qr: "Masquer le QR" nostr_section: "Paramètres nostr avancés" - backup_caption: "Contient la clé et le nom d'utilisateur de votre identité actuelle." + backup_caption: "Contient votre portefeuille et toutes vos identités." danger_zone: "Zone de danger" delete_warning: "Ceci efface votre phrase de récupération et toutes les identités de cet appareil. Tout ce qui n'a pas été sauvegardé est perdu définitivement." download_backup: "Télécharger la sauvegarde" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Restaurer le portefeuille" wrote_them_down: "Je les ai notés" fill_every_word: "Remplissez chaque mot — touchez un mot pour le modifier, ou collez la phrase." + unlock_backup: "Déverrouiller la sauvegarde" + backup_unlocked: "Sauvegarde déverrouillée. Vos identités seront restaurées à l'ouverture du portefeuille." confirm: kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE" title: "Maintenant prouvez-le" @@ -856,6 +858,7 @@ goblin: connected_nym: "connecté via Tor" connecting_nym: "connexion via Tor…" fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds." + restoring: "Restauration de vos identités…" clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage." pick_username: "Choisir un nom d'utilisateur — facultatif" username_blurb: "Vos amis paient votre nom au lieu d'une longue clé. Facultatif — réclamez-en un à tout moment." diff --git a/locales/ja.yml b/locales/ja.yml index d6560299..0dc43029 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: ".backupファイルを選択" backup_read_failed: "そのファイルを読み込めませんでした。" backup_saved: "バックアップを保存しました" - backup_saved_sub: ".backupファイルは安全に保管してください — このファイルとパスワードの両方を持つ人は誰でもアイデンティティを復元できます。" - backup_file_title: "アイデンティティをバックアップ" - backup_file_blurb: "ユーザー名とキーを含む暗号化された.backupファイルを1つ作成します。ウォレットのパスワードを入力して保護してください。" + backup_saved_sub: ".backupファイルは安全に保管してください。このファイルとパスワードの両方を持つ人は誰でもウォレット全体を復元できます。" + backup_file_title: "ウォレットをバックアップ" + backup_file_blurb: "ウォレットのシードとすべてのアイデンティティを含む暗号化された.backupファイルを1つ作成します。ウォレットのパスワードを入力して保護してください。" backup_write_failed: "ファイルを保存できませんでした。" create_backup: "バックアップを作成" registered: "%{name}を登録しました" @@ -633,7 +633,7 @@ goblin: show_qr: "QRを表示" hide_qr: "QRを非表示" nostr_section: "詳細なnostr設定" - backup_caption: "現在のアイデンティティのキーとユーザー名が含まれます。" + backup_caption: "ウォレットとすべてのアイデンティティが含まれます。" danger_zone: "危険ゾーン" delete_warning: "この端末のリカバリーフレーズとすべてのアイデンティティが消去されます。バックアップしていないものは完全に失われます。" download_backup: "バックアップをダウンロード" @@ -843,6 +843,8 @@ goblin: restore_wallet: "ウォレットを復元" wrote_them_down: "書き留めました" fill_every_word: "すべての単語を入力してください — 単語をタップして編集するか、フレーズを貼り付けてください。" + unlock_backup: "バックアップを解除" + backup_unlocked: "バックアップを解除しました。ウォレットを開くとアイデンティティが復元されます。" confirm: kicker: "ステップ2/3 · ウォレット" title: "確認してみましょう" @@ -857,6 +859,7 @@ goblin: connected_nym: "Tor経由で接続済み" connecting_nym: "Tor経由で接続中…" fresh_key_blurb: "リカバリーフレーズの一部ではない支払い用のキーです — 資金に影響を与えることなく、いつでもローテーションしてプライバシーを保てます。" + restoring: "アイデンティティを復元しています…" clean_slate_blurb: "まっさらな状態にしたいですか? いつでもまったく新しいキーに切り替えられます — 新しいあなたは古いあなたと結び付きません。同じウォレットで、新しい顔になります。" pick_username: "ユーザー名を選択 — 任意" username_blurb: "友人は長いキーの代わりに、あなたの名前で支払います。任意です — いつでも登録できます。" diff --git a/locales/ko.yml b/locales/ko.yml index 2da1d04c..b8211e1e 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: ".backup 파일 선택" backup_read_failed: "해당 파일을 읽을 수 없습니다." backup_saved: "백업 저장됨" - backup_saved_sub: ".backup 파일을 안전하게 보관하세요 — 이 파일과 비밀번호를 모두 가진 사람은 신원을 복원할 수 있습니다." - backup_file_title: "신원 백업" - backup_file_blurb: "사용자 이름과 키가 담긴 암호화된 .backup 파일을 만듭니다. 지갑 비밀번호를 입력해 봉인하세요." + backup_saved_sub: ".backup 파일을 안전하게 보관하세요. 이 파일과 비밀번호를 모두 가진 사람은 지갑 전체를 복원할 수 있습니다." + backup_file_title: "지갑 백업" + backup_file_blurb: "지갑 시드와 모든 신원이 담긴 암호화된 .backup 파일을 만듭니다. 지갑 비밀번호를 입력해 봉인하세요." backup_write_failed: "파일을 저장할 수 없습니다." create_backup: "백업 만들기" registered: "%{name} 등록됨" @@ -633,7 +633,7 @@ goblin: show_qr: "QR 표시" hide_qr: "QR 숨기기" nostr_section: "고급 nostr 설정" - backup_caption: "현재 신원의 키와 사용자 이름이 포함됩니다." + backup_caption: "지갑과 모든 신원이 포함됩니다." danger_zone: "위험 구역" delete_warning: "이 기기의 복구 문구와 모든 신원이 삭제됩니다. 백업하지 않은 항목은 영구적으로 사라집니다." download_backup: "백업 다운로드" @@ -842,6 +842,8 @@ goblin: restore_wallet: "지갑 복원" wrote_them_down: "다 적었습니다" fill_every_word: "모든 단어를 채우세요 — 단어를 탭해서 수정하거나, 문구를 붙여넣으세요." + unlock_backup: "백업 잠금 해제" + backup_unlocked: "백업이 잠금 해제되었습니다. 지갑을 열면 신원이 복원됩니다." confirm: kicker: "2/3단계 · 지갑" title: "이제 확인해 보세요" @@ -856,6 +858,7 @@ goblin: connected_nym: "Tor로 연결됨" connecting_nym: "Tor로 연결하는 중…" fresh_key_blurb: "복구 문구에 속하지 않는 결제 키입니다 — 자금은 그대로 둔 채 언제든 교체해 프라이버시를 지키세요." + restoring: "신원을 복원하는 중…" clean_slate_blurb: "새 출발을 원하시나요? 언제든 완전히 새로운 키로 바꾸세요 — 새로운 나는 이전과 연결되지 않습니다. 같은 지갑, 새로운 얼굴입니다." pick_username: "사용자 이름 선택 — 선택 사항" username_blurb: "친구들이 긴 키 대신 내 이름으로 결제합니다. 선택 사항이며 — 언제든 등록할 수 있습니다." diff --git a/locales/ru.yml b/locales/ru.yml index e0c4fd0a..6f15932e 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Выбрать файл .backup" backup_read_failed: "Не удалось прочитать файл." backup_saved: "Резервная копия сохранена" - backup_saved_sub: "Храните файл .backup в безопасности — любой, у кого есть он И ваш пароль, может восстановить вашу личность." - backup_file_title: "Резервная копия личности" - backup_file_blurb: "Создаёт один зашифрованный файл .backup с именем и ключом. Введите пароль кошелька, чтобы запечатать его." + backup_saved_sub: "Храните файл .backup в безопасности. Любой, у кого есть он И ваш пароль, может восстановить весь ваш кошелёк." + backup_file_title: "Резервная копия кошелька" + backup_file_blurb: "Создаёт один зашифрованный файл .backup с seed-фразой кошелька и всеми личностями. Введите пароль кошелька, чтобы запечатать его." backup_write_failed: "Не удалось сохранить файл." create_backup: "Создать копию" registered: "Зарегистрировано %{name}" @@ -633,7 +633,7 @@ goblin: show_qr: "Показать QR" hide_qr: "Скрыть QR" nostr_section: "Расширенные настройки nostr" - backup_caption: "Содержит ключ и имя пользователя вашей текущей личности." + backup_caption: "Содержит ваш кошелёк и все личности." danger_zone: "Опасная зона" delete_warning: "Это удалит вашу фразу восстановления и все личности на этом устройстве. Всё, что не сохранено в резервной копии, будет потеряно навсегда." download_backup: "Скачать резервную копию" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Восстановить кошелёк" wrote_them_down: "Я записал их" fill_every_word: "Заполните каждое слово — коснитесь слова для редактирования или вставьте фразу." + unlock_backup: "Разблокировать копию" + backup_unlocked: "Копия разблокирована. Ваши личности восстановятся при открытии кошелька." confirm: kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК" title: "Теперь подтвердите" @@ -856,6 +858,7 @@ goblin: connected_nym: "подключено через Tor" connecting_nym: "подключение через Tor…" fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства." + restoring: "Восстановление ваших личностей…" clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо." pick_username: "Выберите имя — необязательно" username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент." diff --git a/locales/tr.yml b/locales/tr.yml index a700284f..75eaa91c 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "Bir .backup dosyası seç" backup_read_failed: "Dosya okunamadı." backup_saved: "Yedek kaydedildi" - backup_saved_sub: ".backup dosyasını güvende tut — hem ona hem de parolana sahip olan kimliğini geri yükleyebilir." - backup_file_title: "Kimliği yedekle" - backup_file_blurb: "Kullanıcı adın ve anahtarınla tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir." + backup_saved_sub: ".backup dosyasını güvende tut. Hem ona hem de parolana sahip olan tüm cüzdanını geri yükleyebilir." + backup_file_title: "Cüzdanı yedekle" + backup_file_blurb: "Cüzdan tohumun ve tüm kimliklerinle tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir." backup_write_failed: "Dosya kaydedilemedi." create_backup: "Yedek oluştur" registered: "%{name} kaydedildi" @@ -633,7 +633,7 @@ goblin: show_qr: "QR göster" hide_qr: "QR gizle" nostr_section: "Gelişmiş nostr ayarları" - backup_caption: "Mevcut kimliğinizin anahtarını ve kullanıcı adını içerir." + backup_caption: "Cüzdanını ve tüm kimliklerini içerir." danger_zone: "Tehlikeli bölge" delete_warning: "Bu, kurtarma kelimelerinizi ve bu cihazdaki tüm kimlikleri siler. Yedeklemediğiniz her şey kalıcı olarak kaybolur." download_backup: "Yedeği indir" @@ -842,6 +842,8 @@ goblin: restore_wallet: "Cüzdanı geri yükle" wrote_them_down: "Onları yazdım" fill_every_word: "Her kelimeyi doldur — düzenlemek için bir kelimeye dokun ya da ifadeyi yapıştır." + unlock_backup: "Yedeği aç" + backup_unlocked: "Yedek açıldı. Kimliklerin cüzdan açıldığında geri yüklenir." confirm: kicker: "ADIM 2 / 3 · CÜZDAN" title: "Şimdi kanıtla" @@ -856,6 +858,7 @@ goblin: connected_nym: "Tor üzerinden bağlı" connecting_nym: "Tor üzerinden bağlanılıyor…" fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür." + restoring: "Kimliklerin geri yükleniyor…" clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz." pick_username: "Bir kullanıcı adı seç — isteğe bağlı" username_blurb: "Arkadaşların uzun bir anahtar yerine adına öder. İsteğe bağlı — istediğin an al." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b1991932..0ab6106f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -551,9 +551,9 @@ goblin: choose_backup_file: "选择 .backup 文件" backup_read_failed: "无法读取该文件。" backup_saved: "备份已保存" - backup_saved_sub: "妥善保管 .backup 文件——同时拥有它和你密码的人都能恢复你的身份。" - backup_file_title: "备份身份" - backup_file_blurb: "创建一个包含你的用户名和密钥的加密 .backup 文件。输入钱包密码以封存它。" + backup_saved_sub: "妥善保管 .backup 文件。同时拥有它和你密码的人都能恢复你的整个钱包。" + backup_file_title: "备份钱包" + backup_file_blurb: "创建一个包含你的钱包助记词和所有身份的加密 .backup 文件。输入钱包密码以封存它。" backup_write_failed: "无法保存文件。" create_backup: "创建备份" registered: "已注册 %{name}" @@ -633,7 +633,7 @@ goblin: show_qr: "显示二维码" hide_qr: "隐藏二维码" nostr_section: "高级 nostr 设置" - backup_caption: "包含您当前身份的密钥和用户名。" + backup_caption: "包含你的钱包和所有身份。" danger_zone: "危险区域" delete_warning: "这将删除此设备上的恢复助记词和所有身份。未备份的内容将永久丢失。" download_backup: "下载备份" @@ -842,6 +842,8 @@ goblin: restore_wallet: "恢复钱包" wrote_them_down: "我已抄好" fill_every_word: "填写每个词 — 点击某个词进行编辑,或粘贴整个短语。" + unlock_backup: "解锁备份" + backup_unlocked: "备份已解锁。钱包打开时将恢复你的身份。" confirm: kicker: "步骤 2 / 3 · 钱包" title: "现在来验证" @@ -856,6 +858,7 @@ goblin: connected_nym: "已通过 Tor 连接" connecting_nym: "正在通过 Tor 连接…" fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。" + restoring: "正在恢复你的身份…" clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。" pick_username: "选择用户名 — 可选" username_blurb: "朋友支付给你的名称,而不是一长串密钥。可选——随时认领。" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 27a59fd1..73373d0b 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -5413,7 +5413,7 @@ impl GoblinWalletView { if w::big_action(ui, &t!("goblin.settings.create_backup"), false) .clicked() { - match wallet.create_nostr_backup(&bk.password) { + match wallet.create_full_backup(&bk.password) { Ok(envelope) => { let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M"); let fname = format!("GOBLIN-{stamp}.backup"); diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 2f5b2fcc..ebda7504 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -82,6 +82,34 @@ pub struct OnboardingContent { import: Option, /// Moment the recovery phrase was copied, for the transient "Copied" check. words_copied: Option, + /// Full-backup restore (restore-from-seed path): pick a `.backup` file that + /// carries the money seed AND every identity, unlock it, and let the seed feed + /// the normal 24-word creation path. + backup_restore: Option, + /// Captured at full-backup unlock: `(file contents, backup password)` to + /// reinstate every identity once the wallet has been created and opened. + pending_restore: Option<(String, String)>, + /// A full-backup identity restore is running in a worker after wallet open. + restore_busy: bool, + /// Worker result for the identity restore: `Ok(())` or an error message. + restore_result: std::sync::Arc>>>, + /// Sticky error from the identity restore, shown on the identity step. + restore_error: Option, +} + +/// Full-backup restore sub-state on the restore-from-seed words step. +#[derive(Default)] +struct BackupRestore { + /// The picked `.backup` file contents. + blob: String, + /// The password the backup was sealed under. + password: String, + /// Last error (bad file / wrong password), shown inline. + error: String, + /// A native file pick is in flight (Android resolves the path asynchronously). + picking: bool, + /// The seed was decrypted and loaded into the word grid. + unlocked: bool, } /// Onboarding identity-import state. Reuses the wallet password the user just @@ -124,6 +152,11 @@ impl Default for OnboardingContent { claim: ClaimState::default(), import: None, words_copied: None, + backup_restore: None, + pending_restore: None, + restore_busy: false, + restore_result: std::sync::Arc::new(std::sync::Mutex::new(None)), + restore_error: None, } } } @@ -601,6 +634,9 @@ impl OnboardingContent { ); }); ui.add_space(14.0); + // Restore a FULL .backup file: its seed feeds this same word grid, + // and its identities are reinstated once the wallet opens. + self.backup_restore_ui(ui, cb); } else { // Transient "Copied" feedback (the Build 82/89 pattern): a silent // copy of the recovery phrase reads as a dead button. @@ -652,6 +688,111 @@ impl OnboardingContent { self.error_ui(ui); } + /// Restore-from-seed helper: pick a FULL `.backup` file (seed + all + /// identities), unlock it with its password, and load the recovered seed into + /// the word grid so the standard creation path takes over. The identities are + /// stashed in `pending_restore` and reinstated after the wallet opens. + fn backup_restore_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + // Already unlocked: the seed is in the grid; just confirm and stop. + if self.backup_restore.as_ref().is_some_and(|b| b.unlocked) { + ui.label( + RichText::new(t!("goblin.onboarding.words.backup_unlocked")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.pos), + ); + ui.add_space(14.0); + return; + } + if self.backup_restore.is_none() { + self.backup_restore = Some(BackupRestore::default()); + } + // Recovered seed to apply AFTER the `br` borrow ends (avoids a double + // mutable borrow of `self`): (seed phrase, backup blob, backup password). + let mut apply: Option<(String, String, String)> = None; + { + let br = self.backup_restore.as_mut().unwrap(); + // Poll an async (Android) file pick. + if br.picking { + if let Some(path) = cb.picked_file() { + br.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(c) => br.blob = c.trim().to_string(), + Err(_) => { + br.error = t!("goblin.settings.backup_read_failed").to_string() + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::chip(ui, &t!("goblin.settings.choose_backup_file"), false).clicked() { + br.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => match std::fs::read_to_string(&path) { + Ok(c) => br.blob = c.trim().to_string(), + Err(_) => br.error = t!("goblin.settings.backup_read_failed").to_string(), + }, + // Empty string = Android async pick in flight. + Some(_) => br.picking = true, + None => {} + } + } + // Once a full backup is loaded, ask for its password and unlock it. + if crate::nostr::is_full_backup(&br.blob) { + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_restore_bpw")) + .focus(false) + .hint_text(t!("goblin.settings.backup_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut br.password, cb); + }); + ui.add_space(8.0); + ui.add_enabled_ui(!br.password.is_empty(), |ui| { + if w::chip(ui, &t!("goblin.onboarding.words.unlock_backup"), false).clicked() { + match crate::nostr::open_full_backup(&br.blob, &br.password) { + Ok(full) => { + apply = Some(( + full.seed_phrase.clone(), + br.blob.clone(), + br.password.clone(), + )); + } + Err(_) => br.error = t!("goblin.advanced.wrong_password").to_string(), + } + } + }); + } + if !br.error.is_empty() { + ui.add_space(6.0); + ui.label( + RichText::new(&br.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + } + ui.add_space(14.0); + // Apply outside the `br` borrow: fill the word grid with the recovered + // seed and stash the identities for post-open restore. + if let Some((phrase, blob, pw)) = apply { + self.mnemonic_setup + .mnemonic + .import(&ZeroingString::from(phrase)); + self.pending_restore = Some((blob, pw)); + if let Some(br) = self.backup_restore.as_mut() { + br.unlocked = true; + br.password.clear(); + br.error.clear(); + } + } + } + fn confirm_ui( &mut self, ui: &mut egui::Ui, @@ -741,6 +882,21 @@ impl OnboardingContent { wallets.add(w.clone()); match w.open(pass) { Ok(_) => { + // A full-backup restore: reinstate every identity from the + // backup in a worker once nostr is up (the seed itself was + // already restored through this creation path). + if let Some((blob, bpw)) = self.pending_restore.take() { + let wallet_pw = self.pass.clone(); + let wallet = w.clone(); + let slot = self.restore_result.clone(); + self.restore_busy = true; + self.restore_error = None; + std::thread::spawn(move || { + let res = + wallet.restore_full_backup_identities(&blob, &bpw, &wallet_pw); + *slot.lock().unwrap() = Some(res); + }); + } self.wallet = Some(w); self.error = None; self.step = Step::Identity; @@ -860,6 +1016,47 @@ impl OnboardingContent { }); ui.add_space(14.0); + // Full-backup identity restore in flight: poll the worker, and while it + // runs show a restoring card instead of the claim/import UI so the user + // never acts on the throwaway key it is replacing. + if self.restore_busy + && let Some(res) = self.restore_result.lock().unwrap().take() + { + self.restore_busy = false; + if let Err(e) = res { + self.restore_error = Some(e); + } + } + if self.restore_busy { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.restoring")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(14.0); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(300)); + if w::big_action(ui, &t!("goblin.onboarding.identity.open_wallet"), false).clicked() { + return Some(wallet); + } + return None; + } + if let Some(err) = &self.restore_error { + ui.label( + RichText::new(err) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + ui.add_space(14.0); + } + // Optional username claim — the same machinery as Settings. if let Some(msg) = self.claim.result.lock().unwrap().take() { self.claim.checking = false; diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index bbda8658..073b5f13 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -331,10 +331,217 @@ impl NostrIdentity { } } +/// The decrypted contents of a full wallet backup (see [`build_full_backup`]): +/// the money seed phrase plus every held identity (each with its unlocked keys), +/// and the hex of the identity that was active when the backup was made. +pub struct FullBackup { + /// The 24-word grin recovery phrase, in memory only. + pub seed_phrase: String, + /// Hex pubkey of the identity that was active at backup time (may be empty). + pub active: String, + /// Every held identity with its unlocked keys, re-openable by the restorer. + pub identities: Vec<(NostrIdentity, Keys)>, +} + +/// Seal an arbitrary UTF-8 string under a password with NO plaintext, reusing the +/// exact two-layer scheme of [`NostrIdentity::to_encrypted_backup`]: a fresh +/// random wrapper key is password-protected as a NIP-49 ncryptsec (scrypt), and +/// the text is NIP-44-sealed to that key. Returns `(k, d)` — the ncryptsec and +/// the sealed blob. The wrapper key is otherwise meaningless; only recovering the +/// text matters. No new crypto and no new dependency: same primitives the +/// identity backup already uses. +pub fn seal_secret_text( + plaintext: &str, + password: &str, +) -> Result<(String, String), IdentityError> { + let wrapper = Keys::generate(); + let encrypted = EncryptedSecretKey::new( + wrapper.secret_key(), + password, + NCRYPTSEC_LOG_N, + KeySecurity::Medium, + ) + .map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?; + let k = encrypted + .to_bech32() + .map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?; + let d = nip44::encrypt( + wrapper.secret_key(), + &wrapper.public_key(), + plaintext, + nip44::Version::V2, + ) + .map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?; + Ok((k, d)) +} + +/// Reverse [`seal_secret_text`]: unlock the wrapper key with the password, then +/// open the NIP-44-sealed text. A wrong password fails at the ncryptsec layer. +pub fn open_secret_text(k: &str, d: &str, password: &str) -> Result { + let enc = EncryptedSecretKey::from_bech32(k) + .map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?; + let secret = enc + .decrypt(password) + .map_err(|_| IdentityError::WrongPassword)?; + let keys = Keys::new(secret); + nip44::decrypt(keys.secret_key(), &keys.public_key(), d) + .map_err(|_| IdentityError::WrongPassword) +} + +/// Build the contents of a FULL wallet `.backup` file (format version 2): the +/// money seed AND every held identity, all sealed under one password. The seed is +/// sealed with [`seal_secret_text`]; each identity is sealed with the SAME +/// per-identity scheme as the single-identity backup ([`NostrIdentity::to_encrypted_backup`]), +/// so every element is itself a valid v1 identity envelope. No plaintext (no +/// seed, no npub, no name) ever appears in the output. `identities` carries each +/// identity with its already-unlocked keys; `active_hex` is the identity active +/// at backup time. +pub fn build_full_backup( + seed_phrase: &str, + identities: &[(NostrIdentity, Keys)], + active_hex: &str, + password: &str, +) -> Result { + let (k, d) = seal_secret_text(seed_phrase, password)?; + let mut elems = Vec::with_capacity(identities.len()); + for (id, keys) in identities { + elems.push(id.to_encrypted_backup(keys)?); + } + let envelope = serde_json::json!({ + "goblin_backup": 2, + "seed": { "k": k, "d": d }, + "identities": elems, + "active": active_hex, + }); + serde_json::to_string(&envelope).map_err(IdentityError::from) +} + +/// True if `s` is a FULL wallet backup (format version 2 — seed + identities), +/// as opposed to a v1 single-identity backup or a bare nsec. Both formats set +/// `goblin_backup`; only v2 carries a seed, so callers that must create the +/// wallet check this FIRST. +pub fn is_full_backup(s: &str) -> bool { + serde_json::from_str::(s.trim()) + .ok() + .and_then(|v| v.get("goblin_backup").and_then(|x| x.as_u64())) + .map(|ver| ver >= 2) + .unwrap_or(false) +} + +/// Open a full wallet backup with its password, returning the seed phrase, the +/// active identity's hex, and every held identity with its unlocked keys. A wrong +/// password fails at the seed's ncryptsec layer. +pub fn open_full_backup(blob: &str, password: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(blob.trim())?; + let seed = v + .get("seed") + .ok_or_else(|| IdentityError::Key("backup missing seed".into()))?; + let k = seed + .get("k") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing seed key".into()))?; + let d = seed + .get("d") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing seed data".into()))?; + let seed_phrase = open_secret_text(k, d, password)?; + let active = v + .get("active") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let mut identities = Vec::new(); + if let Some(arr) = v.get("identities").and_then(|x| x.as_array()) { + for elem in arr { + let elem_str = elem + .as_str() + .ok_or_else(|| IdentityError::Key("malformed identity element".into()))?; + let (id, keys) = NostrIdentity::from_encrypted_backup(elem_str, password)?; + identities.push((id, keys)); + } + } + Ok(FullBackup { + seed_phrase, + active, + identities, + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn full_backup_roundtrips_seed_identities_and_active() { + // Build a full backup from a seed + several identities, then reopen it: + // the seed text, every identity's key, and the active marker must survive, + // and NOTHING sensitive (seed word, npub, name) may appear in the file. + let seed = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon art"; + let (mut a, ka) = NostrIdentity::create_random("walletpw").unwrap(); + a.nip05 = Some("alice@goblin.st".to_string()); + a.anonymous = false; + let (b, kb) = NostrIdentity::create_random("walletpw").unwrap(); + let active_hex = b.pubkey_hex().unwrap(); + let ids = vec![(a.clone(), ka.clone()), (b.clone(), kb.clone())]; + + let blob = build_full_backup(seed, &ids, &active_hex, "walletpw").unwrap(); + assert!(is_full_backup(&blob)); + // A full backup is NOT a v1 single-identity backup, but the shared + // `goblin_backup` marker still reads as "an encrypted backup". + assert!(NostrIdentity::is_encrypted_backup(&blob)); + // Opaque: no seed word, no npub, no username leaks. + assert!(!blob.contains("abandon")); + assert!(!blob.contains(&a.npub)); + assert!(!blob.contains(&b.npub)); + assert!(!blob.contains("alice")); + + let opened = open_full_backup(&blob, "walletpw").unwrap(); + assert_eq!(opened.seed_phrase, seed); + assert_eq!(opened.active, active_hex); + assert_eq!(opened.identities.len(), 2); + // Both identities and their keys restore exactly, metadata intact. + let npubs: Vec<_> = opened + .identities + .iter() + .map(|(i, _)| i.npub.clone()) + .collect(); + assert!(npubs.contains(&a.npub)); + assert!(npubs.contains(&b.npub)); + let restored_a = opened + .identities + .iter() + .find(|(i, _)| i.npub == a.npub) + .unwrap(); + assert_eq!(restored_a.0.nip05.as_deref(), Some("alice@goblin.st")); + assert!(!restored_a.0.anonymous); + assert_eq!(restored_a.1.public_key(), ka.public_key()); + // Wrong password opens nothing. + assert!(open_full_backup(&blob, "wrong").is_err()); + } + + #[test] + fn old_single_identity_backup_is_not_a_full_backup() { + // A v1 single-identity envelope must NOT be mistaken for a full backup, and + // must keep restoring through the v1 path exactly as before. + let (a, keys) = NostrIdentity::create_random("pw-1").unwrap(); + let v1 = a.to_encrypted_backup(&keys).unwrap(); + assert!(NostrIdentity::is_encrypted_backup(&v1)); + assert!(!is_full_backup(&v1), "v1 must not read as a full backup"); + let (restored, _) = NostrIdentity::from_encrypted_backup(&v1, "pw-1").unwrap(); + assert_eq!(restored.npub, a.npub); + } + + #[test] + fn seal_secret_text_roundtrips_and_is_opaque() { + let secret = "the quick brown fox"; + let (k, d) = seal_secret_text(secret, "pw").unwrap(); + assert!(!k.contains(secret) && !d.contains(secret)); + assert_eq!(open_secret_text(&k, &d, "pw").unwrap(), secret); + assert!(open_secret_text(&k, &d, "nope").is_err()); + } + #[test] fn backup_restores_under_new_password() { // Export under one wallet password, restore on a device with another. diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index e30d2ab1..6a9f208b 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -29,7 +29,9 @@ mod store; pub use store::NostrStore; mod identity; -pub use identity::{IdentitySource, NostrIdentity}; +pub use identity::{ + FullBackup, IdentitySource, NostrIdentity, build_full_backup, is_full_backup, open_full_backup, +}; pub mod identities; pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since}; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 0d20af6d..f0f8fc47 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -622,6 +622,14 @@ impl Wallet { old.unlock(&password) .map_err(|_| "Wrong password".to_string())?; let input = input.trim(); + // A FULL wallet backup carries a seed and belongs to wallet creation, not + // to swapping one identity in an existing wallet. Reject it here with a + // clear pointer rather than failing obscurely on the v1 path below. + if crate::nostr::is_full_backup(input) { + return Err("That's a full wallet backup. Restore it when creating a \ + wallet, not here." + .to_string()); + } let bpw = backup_password .as_deref() .filter(|s| !s.is_empty()) @@ -702,21 +710,137 @@ impl Wallet { Ok(new_npub) } - /// Build the contents of a `GOBLIN-*.backup` file: the whole nostr identity, - /// fully sealed under the wallet password. Verifies the password first. - pub fn create_nostr_backup(&self, password: &str) -> Result { - let svc = self - .nostr_service() - .ok_or_else(|| "nostr is not running".to_string())?; - let identity = svc.identity.read().clone(); - let keys = identity - .unlock(password) + /// Build the contents of a `GOBLIN-*.backup` file: the FULL wallet — the money + /// seed AND every held identity — sealed under the wallet password (format + /// version 2). The password both unlocks the seed (via [`get_recovery`], which + /// proves it) and unlocks each identity, so a wrong password fails before any + /// bytes are produced. The plaintext seed is assembled and sealed in memory and + /// never written to disk; the returned string is what the caller saves. + /// + /// [`get_recovery`]: Self::get_recovery + pub fn create_full_backup(&self, password: &str) -> Result { + // Unlock the seed first: this both verifies the wallet password and yields + // the phrase, held only in a zeroizing string that drops at end of scope. + let phrase = self + .get_recovery(password.to_string()) .map_err(|_| "Wrong password".to_string())?; - identity - .to_encrypted_backup(&keys) + let nostr_dir = self.get_config().get_nostr_path(); + let index = + HeldIdentities::load(&nostr_dir).ok_or_else(|| "identities unavailable".to_string())?; + // Unlock every held identity under the same wallet password so each can be + // re-sealed into the backup with its own recoverable key. + let mut identities = Vec::new(); + for entry in &index.identities { + let id = entry + .load(&nostr_dir) + .ok_or_else(|| "identity file unreadable".to_string())?; + let keys = id + .unlock(password) + .map_err(|_| "Wrong password".to_string())?; + identities.push((id, keys)); + } + crate::nostr::build_full_backup(&phrase, &identities, &index.active, password) .map_err(|e| format!("backup failed: {e}")) } + /// Restore every identity from a FULL backup into THIS freshly created wallet, + /// re-encrypting each under the wallet password and making the backup's active + /// identity active. The seed itself was already restored through the normal + /// 24-word wallet-creation path (the caller decrypted the seed to feed that + /// path); this method only reinstates the identities. It replaces the fresh + /// random identity the new wallet minted on open with the backed-up active one, + /// then adds the rest as held identities — reusing the same on-disk index and + /// service-rebuild plumbing as add/import. `blob` is the full-backup file, + /// `backup_password` the password it was sealed under (may differ from this + /// wallet's on a new device), `wallet_password` this wallet's password. + pub fn restore_full_backup_identities( + &self, + blob: &str, + backup_password: &str, + wallet_password: &str, + ) -> Result<(), String> { + let full = crate::nostr::open_full_backup(blob, backup_password) + .map_err(|_| "Couldn't open the backup — wrong password?".to_string())?; + if full.identities.is_empty() { + return Ok(()); + } + // The new wallet starts nostr asynchronously on open; wait for the service + // so we replace a fully-initialized identity set rather than racing it. + let mut svc = None; + for _ in 0..200 { + if let Some(s) = self.nostr_service() { + svc = Some(s); + break; + } + thread::sleep(Duration::from_millis(100)); + } + let svc = svc.ok_or_else(|| "nostr didn't start".to_string())?; + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + // Re-encrypt every backed-up identity under THIS wallet's password. + let mut rebuilt: Vec = Vec::new(); + for (backup, keys) in &full.identities { + let mut ident = NostrIdentity::from_unlocked_keys(keys, wallet_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.private_tag = backup.private_tag.clone(); + rebuilt.push(ident); + } + // The active identity (as marked at backup time; else the first) becomes + // identity #1 in identity.json, overwriting the throwaway random key the + // fresh wallet minted on open. A clean single-entry index is then written + // so the discarded random key leaves no held entry behind. + let active_pos = rebuilt + .iter() + .position(|i| i.pubkey_hex().as_deref() == Some(full.active.as_str())) + .unwrap_or(0); + let primary = rebuilt[active_pos].clone(); + primary + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + let mut index = HeldIdentities::from_legacy(&primary) + .ok_or_else(|| "identity has a malformed key".to_string())?; + index + .save(&nostr_dir) + .map_err(|e| format!("index save failed: {e}"))?; + let primary_hex = primary.pubkey_hex(); + for (i, ident) in rebuilt.iter().enumerate() { + if i == active_pos || ident.pubkey_hex() == primary_hex { + continue; + } + // Skip duplicates / cap overflow without aborting the whole restore. + if let Err(e) = index.add(&nostr_dir, ident) { + warn!("nostr: skipping identity during restore: {e}"); + } + } + // Rebuild the live service on the restored set, active identity active. + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path())); + let store = NostrStore::new(config.get_nostr_db_path()); + let (recv, active_hex) = self + .unlock_all_identities(&nostr_dir, wallet_password) + .ok_or_else(|| "identity unlock failed".to_string())?; + 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: restored {} identit(ies) from full backup", + rebuilt.len() + ); + Ok(()) + } + // ── Held nostr identities (one wallet, one balance, many front doors) ────── // // One grin seed / one balance, but the wallet can HOLD several nostr @@ -802,6 +926,11 @@ impl Wallet { let (identity, _keys) = match import { Some(blob) => { let blob = blob.trim(); + if crate::nostr::is_full_backup(blob) { + return Err("That's a full wallet backup. Restore it when \ + creating a wallet, not here." + .to_string()); + } 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